Compare commits

..
Author SHA1 Message Date
Sanderhoff-alt e4e5f8b285 fix(embed): inherit unset LLM settings instead of overwriting them (#3253) (#3359)
HindsightEmbedded forwarded every LLM/daemon setting on every construction, using
placeholder defaults for the ones the caller never mentioned. The embed manager
merges the caller's config over the profile .env and copies any non-None
HINDSIGHT_* entry into the daemon environment, so a client built without
credentials overwrote a key inherited from the profile or the parent shell --
and _register_profile then persisted the placeholders back into the profile's
.env file, leaving a profile configured for anthropic recorded as groq on disk.

llm_provider, llm_api_key, llm_model, log_level and idle_timeout now default to
None and are omitted when not passed, so the daemon resolves them from the
profile .env, then the parent environment, then its own defaults. An explicit
empty string remains an override, which is how a local LLM service with no
authentication clears an inherited key.
2026-08-19 12:09:57 +02:00
Nicolò Boschi 6582e26ef9 feat(mcp): expose knowledge-base CRUD as native MCP tools (#3486) (#3611)
The knowledge base was reachable only over HTTP, so an MCP client had to fall
back to a second integration path to browse or maintain it. Register the seven
agent-facing operations as native MCP tools, with the same bank scoping, tenant
auth and operation-validator behaviour as the existing tools:

  get_knowledge_base_tree, search_knowledge_base, get_knowledge_page,
  create_knowledge_folder, create_knowledge_page, update_knowledge_node,
  delete_knowledge_node

export_knowledge_base stays HTTP/CLI-only — it returns the whole bank as one
markdown bundle, which does not belong in an agent's context window.

Two places where the MCP surface cannot mirror the HTTP one, both commented at
the call site:

- MCP arguments cannot express an explicit null, so update_knowledge_node reads
  parent_id="root" as "move to the top level". Node ids are prefixed kf-/kp-, so
  the literal cannot collide with a real folder id.
- The page refresh trigger is flattened to a single refresh_after_consolidation
  flag, matching how create/update_mental_model already expose it. It is sent as
  a patch, so an unstated flag leaves the knowledge-page defaults (delta mode,
  observation-only) intact — the regression #3506 fixed.

get_knowledge_page returns the rendered markdown document once instead of the
HTTP body+markdown pair, which would double the tokens for no new information.
search_knowledge_base clamps limit instead of rejecting it: an agent that asked
for 500 pages wants results, not a 422.

Also adds a structural guard that the three hand-maintained tool allowlists
(_ALL_TOOLS, register_mcp_tools()'s default set, and the single-bank set in
create_mcp_server) agree with what is actually registered — a name added to one
but not the others silently drops the tool from the endpoint.
2026-08-19 11:58:18 +02:00
Nicolò Boschi 188eaa3dc7 fix(coding-agents): honor retainSessions in the hook harnesses (#3596) (#3607)
* fix(coding-agents): honor retainSessions in the hook harnesses (#3596)

`retainSessions` was parsed, defaulted, env-mapped and accepted as a known
config key, but only `RuntimeCore` (opencode, Kilo, Cline, Prime Agent, dsh)
ever read it. The shared Stop-hook flow behind every hook harness — claude-code,
codex, cursor-cli, copilot-cli, devin-cli, grok-build, antigravity-cli — checked
`disabled` twice and `retainSessions` never, so `retainSessions: false` (global,
per-harness or in a `banks.<id>` section) wrote the transcript back anyway. The
comment claimed this was deliberate while the docs sold the flag as a general
write-back opt-out, including a per-bank example.

Gate the write-back in `runRetainHook`, after `applyBankConfig` so a bank
section can flip it either way, and before `ensureDaemon` — a session that
writes nothing has no reason to bring a server up. A `retain_disabled` diag
record replaces the `retain_ok` that used to appear, so the opt-out is
verifiable in the diagnostic log.

`deepen`'s conversation-history import is the same door one session later: it
reads the harness's own history files and files them as `chat:<id>`. Honoring
the flag in only one of the two places would have left the opt-out cosmetic, so
it skips the import too. Git ingest, seeding, knowledge pages, recall and the
memory tools are all untouched — that separation is what distinguishes this flag
from the `disabled` kill switch.

Tests: four end-to-end `runRetainHook` cases (default writes; global false
writes nothing and builds no client; a bank override opts one repo out; a bank
override re-enables under a global opt-out), two of which fail against the
pre-fix code. Plus a family-wide structural guard in the shape of
`daemon.test.ts`'s "every harness entrypoint reaches a daemon": every module
calling `retainLiveSession` or `ingestChats` must consult the flag. The path
that forgot is by definition the one with no test, so the guard is asserted over
the whole family rather than per-harness.

* chore(docs): re-sync the coding-agents page after the README reflow
2026-08-19 11:51:25 +02:00
Nicolò Boschi 39de3974ea fix(coding-agents): keep a long-lived host's credential live, and say which one it used (#3600) (#3606)
* fix(coding-agents): keep a long-lived host's credential live, and say which one it used (#3600)

`HindsightClient` copied `apiToken` at construction and never re-read it, so a
host that outlives its credential — dsh, Cline, Kilo, Prime Agent, opencode,
the MCP server — kept signing with a key the operator had already replaced.
Enabling auth or rotating the key mid-session 401'd every call until the whole
host restarted, while `hindsight_diagnose` re-read the file and reported the
situation as healthy. The one-shot hook binaries were immune, which is why the
same machine showed working hooks alongside dead in-session tools.

The credential is now resolved through a provider on a 401 and the request
replayed once, but only if the re-resolved token actually CHANGED — a genuinely
wrong key still surfaces as one 401 rather than doubling every failing request.
The happy path never touches the filesystem.

All three fetch paths go through one signing helper. `reflect` and the drain
poll fetched directly, so a recovery wired into `req()` alone would have left
them failing forever.

Both #3600 and the two drifts below come from the same shape: five hosts each
carried their own copy of loadConfig -> deriveBankId -> applyBankConfig ->
new HindsightClient. So the fix is one shared builder (core/host-client.ts)
rather than a sixth line pasted into each. Hoisting it fixes two settings that
had already gone missing that way:

- dsh and Prime Agent never passed `maxParallelRetains`, so both silently
  ignored it and always used the default 10.
- dsh never passed the directory to `applyBankConfig`, so `optInOnly` was not
  enforced there at all: an unapproved repo still got a bank.

`hindsight_diagnose` now reports the credential IN USE next to the one on disk
(booleans only, never the value), resolved through the same pipeline the host
used — including a per-bank `banks.<id>.apiToken`, which a bare loadConfig()
comparison would have reported as a permanent false mismatch. Without this the
drift stays invisible to the one tool whose purpose is to explain it.

A 401 also now says whether a credential was even sent. The server answers
identically for "no key" and "wrong key"; only the client knows which it was.

Behaviour change worth naming: `disabled: true` now wins uniformly. It already
did for every host except the MCP server, which applied the `banks.<id>`
section first and so could be re-enabled per bank; `optInOnly`/`optInPaths` is
the supported way to run memory in only some projects. Resolution also stops
before bank derivation when disabled, since that shells out to git and the
disabled path exists to be a zero-overhead baseline.

Reported with a verified local patch and a full root-cause analysis by
@allenliang2022 in #3600; this implements that approach.

* docs(coding-agents): say when a config change takes effect, and that the token is the exception

Nothing in the README or the skill said when an edit to
~/.hindsight/coding-agent.json actually applies — and the answer differs per
host: a hook harness re-reads the file on every invocation and picks a change
up on the next prompt, a persistent plugin holds it for the life of the agent
process, and the MCP server for the session.

That gap got worse, not better, with the credential fix: the apiToken row now
says it is picked up without a restart, which reads as "config is live" unless
the rule it is an exception to is written down somewhere.
2026-08-19 11:40:41 +02:00
Nicolò Boschi efef4fa398 docs(readme): track hindsight-client downloads, cover the missing concepts (#3608)
* docs(readme): track hindsight-client downloads, cover the missing concepts

The PyPI downloads badge tracked hindsight-api; point it at hindsight-client
(1.6M/month) and make both download badges real links — the NPM one passed
`link=` as a shields param, which is inert in an image.

Relabel the "CI" badge to "Release": it points at release.yml, which only runs
on v* tags, so green meant "the last release published", not "tests pass".
test.yml (the actual CI) has no push trigger, so there is no main history to
badge without changing its triggers.

The LLM Wrapper was pitched as the easiest way in and shown only as a PNG —
uncopyable, unreadable to search engines and coding agents, with no
`pip install hindsight-litellm` and no link to the integration. It is now real
code, and passes hindsight_api_url explicitly because the wrapper defaults to
Cloud, which would otherwise silently send a local-Docker reader to
api.hindsight.vectorize.io.

Add the concepts the README never mentioned: integrations (60+, none were
listed), coding agents, MCP, observations, mental models, knowledge pages,
banks/dispositions, multilingual, Memory Defense, and production concerns.
Add Helm, bare-metal pip and Cloud install paths, plus the Go and CLI clients.

Fix stale facts: link the live benchmarks site next to the January 2026 chart,
and replace the hand-maintained 8-provider list with 25+ and the headline names.

Structure: add a Contents block, move Supported Platforms next to Quick Start
(it sat in the footer, ~200 lines below the anchor pointing at it), and collapse
the three repeated client-setup preambles in the operations section.

* docs(readme): point Cloud mentions at the pricing page

The Cloud links went straight to signup, so a reader had to start an account
to find out what the hosted option actually includes. Point them at
https://vectorize.io/pricing instead, which is Hindsight-specific and compares
self-hosted, Cloud and Enterprise side by side, and summarise what Cloud gives
you (managed scaling, dashboard, backups, 99.9% SLA, usage-based billing with
free credits). Signup stays as the action link next to it.

No prices in the README on purpose — they would go stale here, which is the
same failure mode as the hand-maintained provider list this branch removed.

* docs(readme): drop Pricing from the header nav

The pricing links in the Managed install path and the production table are
where a reader is actually deciding between hosting options; the nav row does
not need a sixth item.
2026-08-19 11:31:43 +02:00
Nicolò Boschi e11a59ff64 fix(coding-agents): timestamp the aggregated git-log document (#3602) (#3605)
`ingestGitLog` retained the commit-message history with no `timestamp`, so retain
stamped "now", the extraction prompt got `Event Date: Unknown`, and every fact
extracted from those messages landed with a null occurred_start/occurred_end —
invisible to temporal search and neutral for recency scoring.

Anchor the document on the newest commit it actually contains (`git log -n 1
--no-merges --format=%aI`, matching gitLogText's traversal, so a merge HEAD does
not misdate it). Null on an empty repo/non-repo, in which case the timestamp is
omitted as before.
2026-08-19 11:01:22 +02:00
Nicolò Boschi 0de91b8b73 fix(coding-agents): let hindsight_reflect wait as long as it is configured to (#3590) (#3592)
The `hindsight_reflect` MCP tool aborted every call at a hardcoded 120s, no
matter what `reflectTimeoutMs` was set to: the handler passed no `timeoutMs`,
so `HindsightClient.reflect()` fell back to its own 120s default. On a
populated bank, `budget: "high"` synthesis routinely runs longer than that —
the identical direct API call succeeded — so the tool was unusable and the
config field was dead.

Both paths that build the tools dropped the setting, not just the one filed:
`selectTools()` (MCP server) and `RuntimeCore.toolSpecs()` (the persistent
plugin harnesses — opencode, Kilo, Cline, dsh, Prime Agent).

The tool's window is now its own knob, `reflectToolTimeoutMs`, defaulting to
330s — above the server's own reflect wall timeout (300s), so the server
decides when to give up rather than an arbitrary client deadline. It inherits
an explicitly raised `reflectTimeoutMs` (the field users already reach for),
but a short one never lowers it: that value bounds an automatic hook which
must fit the host's 25s window, not a call the agent is waiting on.

`reflectBudget` makes the hardcoded `budget: "high"` configurable too, for
large banks where high-budget synthesis exceeds the server's wall timeout.

To stop this recurring, `reflect()`'s `timeoutMs` is now required — the right
deadline differs by an order of magnitude between the hook and the tool, so
there is no sensible default to fall back to silently.
2026-08-19 10:47:31 +02:00
Nicolò Boschi 31c1aaf213 fix(recall): make the recall budget reach the vector index (#3541)
The semantic arm asked the index for `max(limit * 5, 100)` rows, but every pool
connection runs with a fixed `hnsw.ef_search = 200`. In pgvector the candidate list is
the result set — the ground-layer search runs once and the scan ends when that list
drains — so a scan returned at most ~200 rows however large the LIMIT above it, and the
recall budget moved the SQL and nothing else (low/mid/high all got ~200).

Enable hnsw.iterative_scan (strict_order) on recall connections. The drained list is
refilled in ef_search-sized rounds until the query's LIMIT is met, so depth follows each
query's budget with no per-query statement — which matters behind a transaction-mode
pooler, where a session GUC issued between statements can land on a different backend.
Retain-side link probing pins it off; it is tuned for latency, not depth.

Measured on a 40k-row bank, EXPLAIN confirming an ANN index scan:

  MID  unfiltered   200 -> 300 rows,   5.0 ->  4.9ms
  HIGH unfiltered   200 -> 1000 rows,  4.9 ->  6.8ms
  HIGH + filter     200 -> 324 rows,   5.9 -> 14.2ms

+8ms worst case against a ~2.6s recall; the perf suite puts it at +1.3% mean latency /
-1.3% throughput end to end. Memory is not the constraint it looks like: pgvector caps a
resumed scan at work_mem * hnsw.scan_mem_multiplier, but max_scan_tuples binds first —
squeezing work_mem from 64MB to 256kB changes neither rows nor latency.

Two controls, both static server-level config:

- HINDSIGHT_API_ANN_ITERATIVE_SCAN (default true) — the kill switch. False drops the
  resume GUCs rather than sending iterative_scan=off, so a connection is left exactly as
  it was before this existed and the revert lands on the behaviour already in production.
- HINDSIGHT_API_ANN_MAX_SCAN_TUPLES (default 4000, pgvector's own is 20000) — the dial
  that governs the cost. The initial scan is not counted, so even 1 leaves the
  pre-existing depth intact; it interpolates rather than switches.

Separately, the row over-fetch is deleted rather than tuned. It never did anything: each
arm's rows arrive already ordered by distance, so keeping the first N of 5N returns
precisely what LIMIT N would have. Invisible on pgvector, real on backends with no such
bound. The LIMIT is now max(limit, GRAPH_SEED_LIMIT), since the graph arm reads its entry
points from the same rows.

Also fixed: a GUC the server rejects as unknown is remembered and dropped from later
batches, instead of costing a failed batch plus one statement per setting on every
acquire — reachable via pg_trgm on a cluster without it, and via hnsw.iterative_scan on a
pgvector older than 0.8, which reserves the "hnsw." prefix and rejects it outright.
Retain's link probing skips such a GUC too: it applies these with SET LOCAL inside its own
transaction, where an erroring statement would abort the link computation.

Not measured: whether the extra candidates improve answers. Everything above is cost.
2026-08-19 10:46:04 +02:00
github-actions[bot] 9db22115a4 chore: update star history 2026-08-19 03:33:48 +00:00
Nicolò Boschi 8b78b4ac04 test: assert memory state via the engine read API, not raw SQL (#3591)
The suite asserted memory state by querying `memory_units` / `memory_links` /
`unit_entities` directly. That couples tests to the physical schema and makes
them unable to run against any store that keeps memory rows outside Postgres.
This ports what the read API can answer, extends it where it could not, and
marks the residue that is Postgres-shaped by nature.

**Ported to the engine API.** The "unconsolidated count" assertions spelled out
`consolidated_at IS NULL AND consolidation_failed_at IS NULL AND fact_type IN
('experience','world')` — character-for-character what
`list_memory_units(consolidation_state='pending')` already means, so seven sites
across five files became one call. Observation lineage, entity/tag checks and
chunk provenance moved to `list_memory_units` / `get_memory_unit` /
`list_document_chunks`; where the old query was an inner join, the port keeps
the same filtering and says why.

**Read model extended** so the rest could follow: `updated_at` and
`source_memory_ids` on list items, `entity_kind` on entity items, and a
list-valued `fact_type` that matches any of them. Every field is a column of the
row the query already fetched — the projection grows, the plan does not — so no
opt-in flag was needed and production paths pay nothing. Covered by a new test
module driving it all through the engine on retain-written units.

**260 of 6297 tests marked `memory_backend_incompatible`**, in two passes: those
that assert Postgres-internal state (raw `memory_links` counts, `embedding` /
`search_vector`), and those whose fixtures only exist in Postgres. The second set
was chosen on evidence — each both failed against a non-SQL store and touches
those tables in its body, a helper, or a fixture — never by grep alone. Postgres
runs are unchanged; the marker only takes effect behind
`-m 'not memory_backend_incompatible'`.

Also green-lights two checks that were red before this branch: the repo formatter
over five test files, and the coding-agents docs generator, which now rewrites our
own doc links to site-relative the way it already did for assets — the naive
regeneration would have degraded the docs-skill reference's file-relative links.
2026-08-19 00:23:44 +02:00
Nicolò Boschi edd0d0c5bb fix(db): earn per-bank vector indexes by size instead of creating three per bank (#3485) (#3561)
* feat(db): earn per-bank vector indexes by size instead of creating them per bank

Every bank got three partial vector indexes on the shared memory_units table at
creation time. PostgreSQL locks and builds an IndexOptInfo for every index on a
relation at plan time, and opens every one of them for each DML statement, so
each index is a cost paid by queries belonging to every *other* bank. Past a few
thousand banks that exhausts the lock-manager pool: recall and bank deletion
both fail cluster-wide, and deletion failing is what removes the recovery path
(#3485).

A (bank, fact_type) now earns an index once it holds
HINDSIGHT_API_VECTOR_INDEX_MIN_ROWS rows (default 10_000, 0 disables). Below
that the planner serves the same ANN query from idx_memory_units_bank_fact_type
plus a top-N sort — exact rather than approximate, and faster, because sorting a
few thousand rows by distance beats descending an ANN graph. Index count becomes
proportional to the banks large enough to benefit, so bank count stops being a
ceiling.

No request path issues vector-index DDL any more. Bank creation, retain and
import all drop their CREATE INDEX, which also takes that ShareLock on
memory_units off the retain hot path. A new bounded sweep on the MaintenanceLoop
converges the index set instead, with separate build and drop budgets (a build
is an ANN construction; a drop is a catalog operation, and a deployment
recovering from #3485 has tens of thousands to shed) and a short retry interval
while a backlog remains. Bank deletion keeps its drop — it is the only place
that still knows the internal_id the index names derive from.

Cross-tenant discovery goes through a new banks_needing_vector_index() routine,
following the sibling sweeps: one round-trip instead of a per-schema query
storm, with the vanished-schema and lock_timeout arms from c7e9f1a3b5d2 and
c8b4e2a71f95. Concurrency is handled by idempotency, never an advisory lock —
leaning on one is why #2803's version of this sweep was rejected.

The migration deliberately issues no index DDL. An instance already at the wall
cannot plan a statement against memory_units, so a migration that counted rows
to decide what to drop would fail before it could help; DROP INDEX is a utility
statement that locks its own index plus the table, which is why the sweep can
shed indexes while everything else on that relation is failing.

Refs #3485, #2645

* test(vector-index): serialize the reconcile tests onto one xdist worker

Every test in the reconcile suite issues CREATE/DROP INDEX CONCURRENTLY against
the single shared public.memory_units. Concurrent index DDL on one relation
deadlocks by design — CONCURRENTLY holds ShareUpdateExclusive while waiting out
every session whose snapshot could still see the index, including other
sessions' queued index DDL — and eight xdist workers doing that to one table
outlast any retry budget (the storm f9cef24cb was written for). The tests passed
in isolation and deadlocked in the full run, taking test_maintenance_routines
with them.

Advisory locks are banned and an in-process lock cannot reach across xdist
processes, so the isolation has to come from the scheduler, as it already does
for backup_restore and worker_tests.

Also refresh a stale comment in migrations.py: per-bank partial indexes are
owned by the maintenance sweep now, not by bank creation.

* refactor(vector-index): one home for the size policy, and test the sweep body

Two findings from reviewing the branch, both in code this branch added.

should_have_per_bank_index() was reachable only from tests: the reconcile
re-implemented the same predicate inline, so the policy existed twice and only
one copy was load-bearing. Collapsed into qualifies_for_per_bank_index(), which
the reconcile now calls. Its extension argument goes with it — the backend
question is already settled by _vector_index_sweep_enabled and
_vector_index_clause before any reconcile runs, so re-asking it there was a
second, weaker copy of a decision already made.

_run_vector_index_sweep was mocked in every test that mentioned it, so its body
never executed. That body decides which schemas get reconciled, and reconcile
drops every index a schema's partition list does not account for — pointing it
at a schema this deployment does not serve, or one discovery could not read,
destroys indexes it knows nothing about. Now covered: served-but-undiscovered
schemas are skipped and set the backlog, unserved-but-discovered schemas are
never touched, budgets come from config, and a sweep with no DDL connection is a
quiet no-op.

The threshold fixture patches qualifies_for_per_bank_index rather than the old
name, and keeps monkeypatch's default raising=True: if the reconcile ever stops
calling it, the fixture must fail loudly rather than silently leave the
production 10,000-row threshold in place and turn every test below it into a
vacuous pass.

* refactor(db): default to today's coverage, and let writes drive it

Three changes to the size-gated layout, all narrowing it.

The threshold now defaults to off (0 = no minimum). Every bank holding memories
is indexed, exactly as before, so an existing deployment upgrades without
changing behaviour; only a deployment holding thousands of banks sets it. The
knob went in to remove a ceiling on bank count, not to change what a normal
deployment does. An empty partition is excluded explicitly rather than by
arithmetic: at a threshold of 0 the bare `row_count >= minimum` is true for zero
rows, which would have entitled every bank to three indexes over nothing the
moment it was created — the index explosion the threshold exists to prevent,
reintroduced by its own default.

The periodic sweep is gone, replaced by a per-bank async operation. Only a write
moves a bank across the threshold, so there was nothing for a sweep to discover
that the writer did not already know; polling every tenant schema on a timer to
find that out was the wrong shape. submit_async_vector_index_maintenance hangs
off _submit_post_insert_maintenance (retain and import) and off
_handle_consolidation, which is the other writer of memory_units rows —
observations are their own indexed partition. It follows
submit_async_graph_maintenance exactly: plan first and short-circuit with
no_work=True so an unconditional caller pays only a cheap indexed pre-check, and
dedupe by bank including *processing*, since the job re-plans from live counts
when it starts. A failed build is logged, never raised: it leaves the bank on
the exact B-tree path (slower on a large bank, never wrong), the usual cause is
transient DDL contention, and the next write re-queues the work — failing the
operation would surface routine contention as a broken async op.

That removes the cross-tenant discovery routine and its migration, the
MaintenanceLoop job, and the sentinel-row protocol that told a skipped schema
apart from an empty one. It also makes the scoping bug found in review
structurally impossible: planning starts from one bank's internal_id and can only
ever name that bank's three indexes.

The per-pass build/drop budgets are gone. They bounded a sweep that could touch
every bank in the database; one bank's reconcile has nothing to bound.

One capability had to be re-homed. An index whose bank row is gone cannot be
planned from — there is no internal_id left to derive its name — so no
bank-scoped path can reach it. That is exactly the state a deployment at the
#3485 wall is in, because it could not run delete_bank at all. `repair-bank
--all` now also runs a catalog-only orphan sweep, keyed off the live internal_id
set so it cannot mistake a live bank's index for a leftover.

A new operation type has three registries to satisfy, and a guard test caught
the first: WORKER_SLOT_TYPE_DEFAULTS (without it no RESERVED_SLOTS env var
exists, so capacity cannot be reserved for it), and the control plane's
OPERATION_TYPE_VALUES + label map across all ten locales (without those the
operations list shows a raw snake_case string and cannot filter on it).

Tests raise the threshold out of reach suite-wide. At the shipped default every
throwaway bank the suite creates would queue an index build, and eight xdist
workers issuing CREATE INDEX CONCURRENTLY against one shared memory_units
deadlock each other by design — landing on whatever unrelated test happens to be
writing. Tests that exercise coverage patch the threshold themselves, and the
two staging helpers now build CONCURRENTLY with retry: a plain CREATE INDEX
takes ShareLock and closes a three-way cycle with another worker's DROP INDEX
CONCURRENTLY.

* fix(db): drop coverage when a bank loses rows, not just when it gains them

Three defects, all on the drop side, all found by asking what happens when
documents are removed rather than when the threshold moves.

**The drop check was dead at the shipped default.** It read
`row_count < per_bank_index_drop_rows()`, and at a threshold of 0 the drop floor
is also 0 — so `0 < 0`, never true. In the configuration almost everyone runs,
an emptied partition kept its ANN index forever. Replaced with an explicit
should_keep_per_bank_index() whose `row_count > 0` term is not an optimisation:
without it every bank ever written to and then cleared accumulates three indexes
over nothing, which is the accumulation the threshold exists to prevent.

**No delete path queued a reconcile.** Retain, import and consolidation did;
document deletion, memory deletion and bulk deletion did not, though each sits
next to a submit_async_graph_maintenance call. A bank pruned below the threshold
kept indexes it no longer earned until the next *write* — and an emptied bank is
exactly the one nobody writes to again. All five row-count-changing paths now go
through one _submit_vector_index_maintenance_quietly helper rather than five
copies of the same try/except.

**`DELETE /memories` was not wired at all.** It routes to
delete_bank(delete_bank_profile=False), which deliberately does not drop indexes
— the full-delete path drops them by name while it still knows the internal_id,
and this path keeps the bank. So clearing a bank left three indexes over zero
rows, permanently. That path now queues a reconcile too.

The handler also hands off to a successor when it finishes, mirroring graph
maintenance's re-submit for work landing between a job's final claim and its
completion. Bounded twice over: the successor's own pre-check short-circuits
once coverage matches, and the hand-off is skipped when a build failed, so a
permanently failing index cannot spin submits.

Tests come at the drop side from the row direction, which is the blind spot that
let all three through — every previous drop test raised the threshold and left
the rows alone, so `0 < 0` was never evaluated. Each new test was verified by
reintroducing its bug and watching it fail.

Also adds the control-plane guard that was missing. Python already asserts every
operation type has worker slot-reservation config (it caught that omission when
this operation type was added); nothing asserted the UI's OPERATION_TYPE_VALUES
and label map cover the same set, so a new type silently renders as raw
snake_case with no filter. Verified by breaking the parity and watching it fail.

* chore(vector-index): refresh a comment the redesign left stale

per_bank_index_min_rows() still explained itself in terms of "the sweep",
which no longer exists — coverage is decided by the write path's pre-check,
the maintenance operation and the admin command. A comment describing a
design that was removed is worse than none: the next reader takes it as
current. Plus ruff-format collapsing two wrapped calls.

* fix(vector-index): reconcile against the engine's own database, not config's

The maintenance job needs a connection of its own — CREATE/DROP INDEX
CONCURRENTLY cannot run on a pooled one inside a transaction — and it got one by
reading HINDSIGHT_API_DATABASE_URL back out of config. That is only correct when
the engine was configured from that env var. Hand the engine a DSN directly and
the two diverge: embedders do exactly that, and so does the test suite, which
resolves pg0 in a fixture and never exports the variable. Every reconcile then
opens a connection to some other database and dies on
`relation "public.banks" does not exist`.

PostgreSQLBackend now keeps the DSN its pool was opened with and exposes it, and
the handler uses `migration_database_url or backend.dsn`. The env var stays
first: CONCURRENTLY needs a real backend session for the whole statement, which a
transaction-pooled URL cannot give, and that variable is the documented direct
connection escape hatch — migrations prefer it for the same reason.

CI caught this where local runs structurally could not. The scratch pytest plugin
used locally exports HINDSIGHT_API_DATABASE_URL to work around a port collision,
so config and the real DSN agreed on the dev machine and disagreed in CI. The
regression test therefore reproduces CI's condition rather than the local one: it
points config at a database that does not exist and asserts the indexes are built
anyway.

* style: ruff-format the DSN regression test's assertion message
2026-08-18 18:03:00 +02:00
Nicolò Boschi 5f137bf391 fix(recall): keep the observation graph arm out of a nested-loop plan (#3510) (#3588)
`expand_observations`' scoring join is O(C + U) as a hash join and O(U x C) as a
nested loop, where C is the connected-source set and U the unnested candidate
source ids. PostgreSQL picks between them from its row estimate for the
`connected_sources` CTE, and that estimate was 1 against an actual ~3,700: the
capped column came out of a LATERAL + LIMIT subquery, which carries no
n_distinct statistic, so DISTINCT over it was estimated at 2 and the NOT EXISTS
anti-join took that to 1. A 1-row inner side makes the nested loop look free, so
it won on cost and lost by four orders of magnitude at runtime — 15s and ~15M
rejected join rows on a realistically-shaped bank, matching the plans reported
in the issue.

Rank with row_number() instead. Identical output — same cap, same ordering,
unit_id is unique — but the capped column now traces to unit_entities.unit_id, so
the estimate comes from real statistics (207-3,449 against 2,242-4,193 actual)
and the nested loop is priced honestly. Measured over 12 seed sets on the fixture
below: p50 15,013ms -> 217ms, with the full scored set identical.

The set-difference rewrite proposed in #3512 also clears the reported bank, but
it leaves the estimate at 2 and survives only because a set-op prices the nested
loop just above the hash join: 1.0-1.2x headroom against 1.6-1.8x here.

The trade is that ranking reads every unit_entities row of a matched entity where
the LATERAL stopped at per_entity_limit off the index: O(sum of degree) rather
than O(entities x per_entity_limit). At parity up to ~12k-degree hubs, +50%
traversal cost at 38k.

Why the perf suite never caught it
----------------------------------

`recall-with-observations` measured 0.45s on the same query a realistically
shaped bank runs in 15s. Two fixture properties were wrong, and neither alone
reproduces the bug — measured on the suite's own bank:

                          sources=113   sources=mean 2
    old vocabulary          450ms          270ms
    new vocabulary          951ms       15,013ms

- The entity vocabulary was a fixed 145 names at every scale, so degree grew with
  bank size instead of the entity count growing: 142 entities at median degree 40
  with not one entity mentioned once, and every seed reaching 142 of 142
  entities. It now grows with the corpus (1,354 entities, median degree 3, 449
  mentioned once, seeds reaching 54).
- Sources per observation was a constant. Real counts are long-tailed — the
  reported bank ran mean 1.7 / p95 4 — so it is now the mean of a Pareto draw.
  At mean 2 the fixture still emits observations carrying several hundred
  sources, keeping the array-length path from #3085 exercised.

`recall-with-observations` at scale=large will step up when this lands: the
suite can finally see this query. The SQL fix is in the same change so the
dashboard moves once, not twice.

Oracle's expand_observations has the same DISTINCT-over-LATERAL shape and is
deliberately left alone — no Oracle instance was available to measure it, and its
cardinality estimation differs. Documented in ops_oracle.py.

Tests
-----

- test_per_entity_cap_bounds_hub_traversal pins that the window ranks the same
  rows the LATERAL selected; it fails if the cap is widened or dropped.
- test_perf_fixture_shape asserts the post-resolution entity graph keeps a long
  tail. It simulates the entity resolver's intra-batch fuzzy merge, because the
  tail names have to stay under the 0.5 pg_trgm threshold: a tail generated as
  "<stem> <counter>" scores 0.73 and the resolver collapsed 2,814 names to 159,
  silently restoring the flat graph the vocabulary exists to avoid.
2026-08-18 17:38:24 +02:00
Nicolò Boschi 6692e38c80 fix(api): paginate the bank list (#3586)
* fix(api): paginate the bank list

GET /v1/default/banks returned every bank in the system: no limit, no
offset, and a query with no LIMIT clause. Beyond the unbounded payload, the
per-bank work — config resolution and a live store count for banks whose
memories live outside SQL — ran for every bank rather than the ones being
shown.

The endpoint now takes limit/offset (defaults 100/0, matching
list_documents) plus a `q` substring filter on bank id and name, and
returns total/limit/offset alongside `banks`. Paging happens after
filter_bank_list rather than in SQL: that extension hook can drop any bank,
so a SQL page would hand back short pages and a total counting banks the
caller can't see.

Consumers page instead of taking the first 100: the control-plane bank
selector scrolls infinitely and searches server-side, the CLI walks every
page, and the Zapier bank dropdown became canPaginate.

* fix(api): bound the bank-list probes and keep the selected bank's name

Follow-ups from reviewing the pagination change:
- the control-plane health probe and the Zapier credential test only need to
  know the endpoint answers, so they ask for limit=1 instead of a default page
- the header showed the raw bank id whenever the selected bank sat past the
  first page, so its name is fetched directly
- limit/offset are clamped in the engine: the page is a Python slice, and the
  MCP tool takes both straight from a model with no HTTP-layer validation

* docs(mcp): document list_banks query/limit/offset

* fix(control-plane): make the bank selector actually page and report empty searches

Verified against a 130-bank instance: the infinite scroll never fired. The
observer effect read listRef.current/sentinelRef.current on the commit that
flips the popover open, but Radix mounts the content in a portal afterwards, so
both refs were null and nothing re-ran the effect — the selector sat on its
first 50 banks forever. Tracking the nodes as state through callback refs
re-runs the effect when they attach; paging now walks offset 0/50/100 and stops
at the total.

An empty result also read "No memory banks yet." after a search that simply
matched nothing, so searches get their own message.

* feat(control-plane): smooth the bank selector as pages land and searches narrow

The list is paged and searched server-side now, so rows appear and vanish in
batches — every page landed as a hard 50-row pop, and a search that narrowed to
one bank snapped the popover shut from 300px.

- rows fade and lift in, staggered within their page and capped so the tail of a
  50-row page doesn't crawl; only rows that actually mount animate, so appending
  page 2 leaves page 1 still
- the list height follows cmdk's --cmdk-list-height, easing down to the filtered
  set instead of jumping
- the previous results hold their place and dim while the next set is in flight,
  rather than blanking on every keystroke

The animations are defined in globals.css next to the existing logo keyframes:
tailwindcss-animate is a Tailwind v3 plugin declared in tailwind.config.ts, but
this app runs Tailwind v4 with the CSS-first config, so `animate-in` and friends
compile to nothing here.

* refactor(control-plane): tidy the bank row className and import
2026-08-18 17:27:27 +02:00
Nicolò Boschi df8ac42b52 fix(memory): add resolve_entities flag to update_memory and retain (#3576)
* fix(curation): resolve edited entity names exactly, not fuzzily (#3479)

update_memory ran the caller's entity names through the same fuzzy resolver
retain uses, so an entity name was a *guess* to be reconciled against the graph
rather than an instruction. Name identity is worth at most 0.5 of the 0.6 match
threshold, while co-occurrence (0.3) and recency (0.2) make up the rest, so a
similar-but-wrong entity that is well connected to the other names in the same
edit outscores the one the caller actually named — with a 200 and no warning.

Curation now resolves exactly: an existing entity is reused only when its
canonical name matches case-insensitively, any other name creates its own
entity, and same-batch names are never merged with each other. Retain keeps
fuzzy resolution, which is right for names that came out of extraction.

The exact path skips the trigram/UTL_MATCH probe and the co-occurrence fetch
entirely and reuses the existing find-or-create pass, so it is dialect-agnostic
and strictly less work than the fuzzy one.

* fix(curation): add entity_resolution_mode to update_memory, default fuzzy

Make the exact/fuzzy choice the caller's, rather than changing what an edit
does. `entity_resolution_mode` defaults to "fuzzy" — retain's behaviour, so
every existing caller is unaffected — and "exact" opts into literal matching for
hand-authored corrections.

Plumbed through the HTTP request model, the MCP tool, the control-plane proxy
route and its client, with the engine validating the value for direct callers.
The control-plane memory editor sends "exact": a person typing an entity list
into the admin UI is naming the entity they mean.

* refactor(curation): make the flag a boolean, resolve_entities

Replaces the entity_resolution_mode enum with a plain boolean on update_memory.
`resolve_entities` defaults to True — retain's behaviour, so existing callers are
unaffected — and False takes the submitted names literally.

Carries the same change through the resolver, which now takes `fuzzy_matching`
rather than a mode string. The engine's value guard goes away with the enum: a
bool needs no validation, so the invalid-value test goes too (the HTTP boundary
still 422s a non-boolean, which the HTTP test covers).

* feat(retain): honour resolve_entities for caller-supplied entities too

Same flag, same default, on the retain item — a caller passing explicit entity
names there has the same exposure as one correcting a memory: a name close to an
existing entity can be matched onto it and quietly replaced.

Retain resolves caller-supplied and LLM-extracted names in ONE batch, so a
per-batch flag would have turned resolution off for the extractor's names too and
filled the bank with near-duplicate entities. The flag is therefore carried per
mention: extracted names always resolve, supplied names follow the item's flag,
and a supplied name the extractor also produced keeps the caller's intent. The
in-batch dedup pass (#3107) skips the literal names for the same reason.

Also renames the resolver's `fuzzy_matching` parameter to the per-mention
`resolve` key, so one name is used end to end.

* fix(clients): carry resolve_entities through the maintained wrappers

Code review found two gaps the generated SDKs hide.

The TypeScript and Python convenience wrappers rebuild each retain item field by
field, so `resolve_entities` was silently dropped for every wrapper caller — the
same class of gap #2975/#3042 closed for the mental-model methods, and here it
would have quietly restored the substitution the flag prevents. Both wrappers now
forward it, with mapping tests on each side.

Intake also lost the flag when normalization collapsed two spellings into one:
entity_processing dedups caller-supplied against extracted names on the RAW text,
so a caller's literal "Acme Corp" and the extractor's "Acme\nCorp" both reach
_prepare_entities_for_resolution and only merge there. Keeping the first entry
verbatim dropped the caller's resolve=False with it; the merge now keeps the
stricter flag.

* fix: build the Rust CLI, and keep pg_trgm detection on empty batches

Two CI breaks from the retain change.

MemoryItem gained a field, and the CLI builds it with a struct literal, so every
Rust job failed to compile. The CLI supplies no entities, so `true` (the server
default) is the right value there.

The skip-the-probe guard also fired on an *empty* batch — `any([])` is False — so
_resolve_entities_batch_impl returned before the pg_trgm auto-detection that
hangs off the strategy dispatch. Only shortcut when there is data and none of it
resolves.

* fix(rust): add resolve_entities to the remaining MemoryItem literals

The first pass only fixed the CLI's src/ literal — `cargo build` does not compile
test targets, so the ones in hindsight-cli/tests/integration_test.rs and
hindsight-clients/rust/src/lib.rs went unnoticed until CI. Verified with
`cargo check --all-targets` in both crates this time.
2026-08-18 16:29:33 +02:00
Nicolò Boschi d98990b46e fix(embed): harden daemon and UI lifecycle (#3099, #3100, #3517, #3520, #3527) (#3585)
* fix(embed): harden daemon and UI lifecycle (#3099, #3100, #3517, #3520, #3527)

Five open hindsight-embed issues all sit in the same two files and share one
root theme: the manager decides who to talk to, and who to kill, from evidence
that isn't good enough.

#3520 — `_clear_port`/`stop`/`stop_ui` picked their victim purely by "who holds
the port" and SIGTERMed it. On a host where an unrelated service shared the
port, that service died with no indication of what killed it. A listener is now
only signalled once its command line identifies it as our daemon (or our control
plane); otherwise we log and refuse, and startup fails with "port in use"
instead. A failed start is recoverable; killing someone else's service is not.

#3517 — `_find_pid_on_port` shelled out to `lsof` only, so on Linux hosts
without it (minimal containers, Arch-based distros) every daemon stop logged
"Could not find PID for port" and stopped nothing. PID discovery now falls back
to `ss` (iproute2). It also returns every listener rather than an arbitrary
first PID, which is what lets the ownership check above pick the right one.

#3527 — `is_ui_running` health-checked 127.0.0.1 regardless of the bind
hostname. Next.js started with `--hostname localhost` binds ::1 only, so
`ui start` always timed out after 30s on a UI that was up and serving, and
`ui status` reported it as down. Both loopback families are now probed, in
`_is_port_in_use` and the Windows netstat parse as well, and user-facing URLs
say `localhost` so they resolve whichever way the server bound.

#3099 — the 2s /health client timeout classified a busy daemon as dead. /health
is served from the same event loop as the daemon's LLM calls, so a slow
provider stalls it. The probe budget is now 10s by default (aligned with the
worker-side liveness threshold) and configurable via
HINDSIGHT_EMBED_HEALTH_PROBE_TIMEOUT. The 30s reclaim grace window is unchanged.

#3100 — the Windows lock used `msvcrt.LK_LOCK`, which retries exactly 10 times
internally and then raises, so a concurrent start of the same profile failed
non-deterministically with an opaque OSError. Both platforms now drive the
non-blocking primitive from one bounded retry loop with backoff; on timeout the
error names the lock file and the PID holding it, and `_start_daemon` turns that
into a normal startup failure.

Not included: #3253 (empty llm_api_key clobbering an inherited env var) already
has a fix in the open PR #3359.

* fix(embed): keep the UI probe short and clean up the lock-owner sidecar

Two defects from the previous commit, found in review.

The UI health probe inherited HEALTH_PROBE_TIMEOUT (10s). That budget exists
for the daemon, whose /health sits behind the event loop its LLM calls run on
(#3099); the control plane's /api/health has nothing blocking behind it. Since
start_ui polls the probe inside a 30s budget and now probes two loopback
families, a listener that binds but does not answer would burn the whole budget
in two probes and report a false "UI failed to start (timeout)" — the exact
symptom #3527 is about. The UI keeps its own 2s probe.

delete_profile removed <name>.lock but not the <name>.lock.owner sidecar the
new locking writes, so a crash while holding the lock orphaned a file that
outlived the profile.

Also adds direct coverage for _process_command_line, which decides every kill
but was only reached through tests that patch it out, and documents that
_wait_for_port_health bounds when the last probe starts rather than when it
returns.

* fix(embed): scope the long health budget to the reclaim probe only

test-embed-windows failed on test_delete_profile_over_http with a client-side
ReadTimeout. The control center's delete handler asks is_running once and the
UI probe once per loopback family; at the 10s budget those three serial probes
could reach 14s against httpx's 5s default client timeout. The same path was
~4s before, so a slow connect that Windows already had was being masked.

The budgets are now split by what a wrong answer costs. HEALTH_PROBE_TIMEOUT
(10s, configurable) applies only to _port_health_ok — the probe whose false
negative gets the listener killed, which is what #3099 is actually about.
is_running and the UI probe use LIVENESS_PROBE_TIMEOUT (2s, the pre-existing
value): they only answer "is it up?", and a false negative there costs a re-run
of ensure_running, which consults the long probe before doing anything
destructive. #3099's real harm — a busy daemon being reclaimed as stale — stays
fixed.

Connect is capped separately at 1s. An address that swallows the SYN hangs in
connect rather than read, so this is what actually bounds the handler: three
probes at 1s is 3s, below both the 5s client default and the 4s the two
uncapped probes could reach before this branch.
2026-08-18 15:44:02 +02:00
Nicolò BoschiandSanderhoff-alt 2e2dfe1309 fix(consolidation): keep source dates when observations merge (#3500)
* fix(consolidation): keep source dates when observations merge

Both semantic-dedup folds rewrote only text/sources/proof_count, so an
observation could end up citing dated source facts while reporting no
event interval at all (#3477): the CREATE fold dropped the dates of the
CREATE it skipped, and the UPDATE fold dropped everything the row it
deletes knew. Both now widen the survivor's bounds, and the ordinary
UPDATE carries event_date through like the other three fields.

Diagnosis and the min/max contract come from #3482.

Co-authored-by: Sanderhoff-alt <[email protected]>

* fix(consolidation): make the observation date merge Oracle-safe

The LEAST/GREATEST widening in _execute_update_action also runs on Oracle
(writes_memory_rows_in_sql_for is true for the default store on both backends,
and the observation_sources sync below it is the Oracle-only branch of the same
function). Oracle returns NULL from LEAST/GREATEST as soon as ANY argument is
NULL, where PostgreSQL ignores NULL arguments.

COALESCE($n, col) only guards a NULL parameter, not a NULL column — so on Oracle
an observation with no occurred interval yet computed LEAST(NULL, <source date>)
= NULL and silently dropped the date it was told to inherit. That is exactly the
#3477 scenario, which meant the fix landed on PostgreSQL only.

Wrap each assignment in one more COALESCE. Provably a no-op on PostgreSQL: the
outer COALESCE fires only when LEAST/GREATEST yields NULL, which there requires
both operands to be NULL, and the fallback is then NULL too. The inner
COALESCE($n, col) is kept spelled exactly as before because the Oracle driver
shim keys its TIMESTAMP-TZ input-size hint off that pattern.

event_date and mentioned_at were never actually at risk (_create_observation_-
directly stamps both), but they take the same form so all four columns read
alike. The two dedup folds keep the plain idiom: _dedup_active disables dedup on
Oracle, so they are PostgreSQL-only by construction — noted in _TemporalBounds.

Adds an oracle-marked regression test for the NULL-column widening; the PG side
is already covered by test_consolidation_temporal_merge.py. Also tightens
_retain_fact to assert it stamped exactly one fact instead of returning an
arbitrary row from a multi-row UPDATE.

* test(ci): let the Oracle bootstrap run as a non-admin user

Every Oracle test has been erroring in session setup with ORA-01031. The
workflow provisions hindsight_test with a privileged account in a 'Setup Oracle
test user' step, then sets ORACLE_TEST_DSN to that same unprivileged user — so
the oracle_db_url fixture connects as hindsight_test and tries to CREATE USER
HINDSIGHT_TEST, which it has no privilege to do. Oracle checks privileges before
name conflicts, so it raises ORA-01031 rather than the ORA-01920 the fixture
tolerates, and the whole session dies before a single test runs.

Tolerate ORA-01031 the same way: it means the user was provisioned externally,
which is exactly what CI does. The GRANTs below already swallow it. If the user
really is missing, run_migrations() still fails loudly on login.

Unblocks test-api-oracle, which is label-gated and so had gone unnoticed.

* test(oracle): compare timestamps as instants, not naive vs aware

The new Oracle regression test proved the fix works — occurred_start came back
as 2020-03-01 where the unfixed statement would have left it NULL — but then
failed anyway: oracledb returns that column without a tzinfo, and comparing a
naive datetime to a UTC-aware one is silently False rather than an error.

Normalise through _as_utc before comparing.

---------

Co-authored-by: Sanderhoff-alt <[email protected]>
2026-08-18 15:33:09 +02:00
224 changed files with 10443 additions and 2088 deletions
+32
View File
@@ -77,6 +77,7 @@ results = await asyncio.gather(*tasks, return_exceptions=True)
- **No direct database access in `api/http.py`** (or any API router). HTTP handlers must not build SQL, call `acquire_with_retry` / `conn.fetch` / `conn.fetchrow` / `conn.execute`, or reference `fq_table(...)`. All persistence and queries live in `MemoryEngine` (the engine layer). A handler parses/validates the request, calls an engine method, shapes the HTTP response, and maps domain results to status codes (e.g. a `None` return → 404).
- **Authentication/tenancy is enforced inside each engine method, not assumed by the handler.** Every engine method that touches bank-scoped data must authenticate via `request_context` — typically `await self._authenticate_tenant(request_context)` (often indirectly through `get_bank_profile(...)`) — so the correct tenant schema is resolved before any query runs. Handlers must thread `request_context` through to the engine method; never query a tenant-scoped table assuming the schema is already set.
- Engine methods return typed models (Pydantic/dataclass), not raw dicts (see Type Safety).
- **Every list endpoint paginates, following the existing ones.** A `GET` that returns a collection whose size grows with the data (banks, documents, memories, entities, operations, webhook deliveries, audit logs, …) must take `limit`/`offset` and bound its result — an unbounded list is an unbounded payload plus unbounded per-row work (per-item counts, config resolution, embedding hydration). Copy the shape `list_documents` uses, don't invent a new one: `limit: int = Query(default=100, ge=0)` and `offset: int = Query(default=0, ge=0)` on the handler, matching keyword args on the engine method, and a response carrying the page **plus `total`, `limit`, `offset`** so a client knows when to stop. Add a `q` search param when the collection is something a user picks from in a UI — client-side filtering only ever sees the loaded page. Bounded-by-construction endpoints are the exception, not the rule: a tree/export that is whole-structure by design, or a table capped at write time (e.g. `observation_history` / `mental_model_history`, trimmed to `*_max_entries` on insert). If it isn't bounded, paginate it.
### Bank/Tenant Isolation in Queries
- **Bank isolation is a hard security invariant: no query may read, count, update, or delete another bank's rows.** Tenant isolation is enforced at the schema level (the resolved `search_path` / `fq_table(...)` qualifier, gated by `_authenticate_tenant`); bank isolation is enforced *within* a schema by a `bank_id` predicate on every statement that touches a multi-bank table.
@@ -163,6 +164,28 @@ Flag any new logic that lacks test coverage.
See CLAUDE.md → Key Conventions → Testing for the full pattern.
### 6a. Check tests assert memory state via the engine API, not raw SQL
Tests must verify what a retain / recall / consolidation produced by calling the public
`MemoryEngine` read API — `list_memory_units` (units and their `metadata` / `tags`; counts via
`total`; `document_id` / `fact_type` / `entity_id` filters), `list_entities` (canonical names,
mention counts), `get_graph_data` (nodes/edges), `get_bank_stats`, `recall_async`**not** by
reaching into the memory tables (`memory_units`, `memory_links`, `unit_entities`) with raw SQL via
`pool.acquire()` / `conn.fetch*`. Asserting on those tables couples the test to a storage-layer
detail and checks a proxy instead of the observable property (see **General Principles** → tests
assert the property, and the handler rule in **7b**).
**Flag as should fix** any added or changed test whose assertion runs a `SELECT` / `COUNT` against
`memory_units` / `memory_links` / `unit_entities` where an engine read method returns the same
fact. Prime tell: `async with pool.acquire() as conn:` followed by `SELECT ... FROM memory_units`
inside a test body; a `fetchval("SELECT count(*) FROM memory_units ...")` that `list_memory_units`
`["total"]` would return; a `canonical_name` query that `list_entities` covers.
Direct SQL on those tables is legitimate **only** when it forces or inspects internal state the
public API cannot express — e.g. an `UPDATE documents SET updated_at` that forges a race, or a
raw `memory_links` row-count that the deduped `get_graph_data` edge list cannot reproduce. Those
must carry a comment saying why the direct access is necessary; flag any that do not.
### 7. Check API consistency
If any files in `hindsight-api-slim/hindsight_api/api/` were changed:
@@ -199,6 +222,15 @@ For each statement against a multi-bank table (`memory_units`, `documents`, `ent
**Flag as a must fix** any statement filtering a multi-bank table by a caller-supplied, non-globally-unique key (`document_id`, `mental_models.id`, an entity name, …) with **no** `bank_id` predicate — construct the concrete two-bank scenario (two banks share the id; the statement reads/counts/updates/deletes the wrong bank's rows or over-reports) to confirm it's real before flagging. Prime tells: a `bank_id`-carrying sibling statement right next to a `bank_id`-less one; a `WHERE bank_id` guarded by `if bank_id:` with a `None` default; an import/transfer write that inherits a source `bank_id` instead of pinning the destination.
### 7d. Check list endpoints paginate
For every added or changed `GET` handler that returns a collection, confirm it takes `limit`/`offset` and returns `total` — see **API Layer & Data Access** above for the exact shape. Then check the fix is real end to end, since a param that nothing enforces is worse than none:
- **The bound reaches the work, not just the response.** Verify the page size actually limits the expensive part — the SQL `LIMIT`/`OFFSET`, or (when paging must happen after an in-process filter, as in `list_banks` where the `filter_bank_list` extension hook can drop any bank) an explicit slice with the per-item work — live store counts, `get_bank_configs`, re-embedding — done for the page only. Paging in SQL *before* a filter that can drop rows is a **must fix**: it hands back short or empty pages and a `total` that counts rows the caller can't see.
- **Every in-repo consumer pages.** A new default `limit` silently truncates callers that used to get everything: the control plane (`src/lib/api.ts` + the `src/app/api/` proxy route + any context/selector that holds the full list), the CLI (`hindsight-cli/src/api.rs`), MCP tools, and the Zapier dynamic dropdowns. Each must either page through to completion or expose paging in its UI — flag any consumer left on a single default-sized page.
- **Search moves server-side with it.** A picker that filtered client-side over the full list now only filters the loaded page. If the endpoint gained `q`, the UI must send it (and disable its local filtering, e.g. cmdk's `shouldFilter={false}`); if it didn't, say why the collection is small enough not to need it.
- **Tests that look up their own row must not depend on landing on page 1** — they should search or pass an explicit `limit`, not rely on default ordering.
### 8. Check code comments
For each non-trivial change:
+14
View File
@@ -188,9 +188,23 @@ HINDSIGHT_API_LOG_LEVEL=info
# Vector Extension (Optional - uses pgvector by default)
# Options: "pgvector" (default), "vchord", "pgvectorscale" (DiskANN)
# HINDSIGHT_API_VECTOR_EXTENSION=pgvector
# Let a vector index scan resume until the query's LIMIT is satisfied, instead of
# stopping when its first candidate list drains (pgvector: hnsw.ef_search, 200) — with
# it off, a larger recall budget cannot retrieve more rows. Needs pgvector 0.8.0+;
# older servers reject it and it is dropped automatically. Set false and restart as a
# quick revert to the previous retrieval depth, with no code change.
# HINDSIGHT_API_ANN_ITERATIVE_SCAN=true
# Ceiling on tuples one resumed scan may visit. Bounds the CPU and memory a selective
# query can spend resuming (filters are applied after the scan, so it resumes often).
# Lower it to trade depth back for latency. pgvector's own default is 20000.
# HINDSIGHT_API_ANN_MAX_SCAN_TUPLES=4000
# For Azure PostgreSQL with DiskANN:
# HINDSIGHT_API_VECTOR_EXTENSION=pgvectorscale # Auto-detects pg_diskann on Azure
# Per-bank vector indexes (pgvector / pgvectorscale / vchord only; ScaNN and Oracle use one global index)
# HINDSIGHT_API_VECTOR_INDEX_MIN_ROWS=0 # Memories a bank needs in one fact type before that fact type gets its own vector index. 0 (default) = no minimum, every bank holding memories is indexed. Set ~10000 on deployments with thousands of banks: every index lives on the shared memory_units table and is planned against by every OTHER bank's queries, so unconditional per-bank indexes put a ceiling on bank count. Smaller banks then use exact search, which is faster AND exact.
# Text Search Extension (Optional - uses native PostgreSQL full-text search by default)
# Backend options: "native" (default), "vchord", "pg_textsearch", "pgroonga", "pg_search"
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION=native
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

+4
View File
@@ -1044,6 +1044,10 @@
{
"date": "2026-08-18",
"stars": 20114
},
{
"date": "2026-08-19",
"stars": 20214
}
]
}
+259 -148
View File
@@ -2,13 +2,14 @@
![Hindsight Banner](./hindsight-docs/static/img/hindsight-github-banner.png)
[Documentation](https://hindsight.vectorize.io) • [Paper](https://arxiv.org/abs/2512.12818) • [Cookbook](https://hindsight.vectorize.io/cookbook) • [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup)
[Documentation](https://hindsight.vectorize.io) • [Integrations](https://hindsight.vectorize.io/integrations) • [Cookbook](https://hindsight.vectorize.io/cookbook) • [Benchmarks](https://benchmarks.hindsight.vectorize.io/) • [Paper](https://arxiv.org/abs/2512.12818) • [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup)
[![CI](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml/badge.svg)](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml)
[![Release](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml/badge.svg)](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml)
[![Version](https://img.shields.io/pypi/v/hindsight-api?logo=python&logoColor=white&label=version&color=blue)](https://pypi.org/project/hindsight-api/)
[![PyPI Downloads](https://img.shields.io/pypi/dm/hindsight-client?logo=pypi&logoColor=white&label=PyPI&color=blue)](https://pypi.org/project/hindsight-client/)
[![NPM Downloads](https://img.shields.io/npm/dm/%40vectorize-io%2Fhindsight-client?logo=npm&logoColor=white&label=NPM&color=blue)](https://www.npmjs.com/package/@vectorize-io/hindsight-client)
[![Slack Community](https://img.shields.io/badge/Slack-Join%20Community-4A154B?logo=slack)](https://join.slack.com/t/hindsight-space/shared_invite/zt-3nhbm4w29-LeSJ5Ixi6j8PdiYOCPlOgg)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
![PyPI - Downloads](https://img.shields.io/pypi/dm/hindsight-api?label=PyPI)
![NPM Downloads](https://img.shields.io/npm/dm/%40vectorize-io%2Fhindsight-client?logoColor=orange&label=NPM&color=blue&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40vectorize-io%2Fhindsight-client)
<br/>
<a href="https://trendshift.io/repositories/15603" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15603" alt="vectorize-io%2Fhindsight | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -20,28 +21,33 @@
Hindsight™ is an agent memory system built to create smarter agents that learn over time. Most agent memory systems focus on recalling conversation history. Hindsight is focused on making agents that learn, not just remember.
<video src="https://github.com/user-attachments/assets/923b798d-3581-4897-bb62-9cfa5a931682" controls></video>
It eliminates the shortcomings of alternative techniques such as RAG and knowledge graph and delivers state-of-the-art performance on long term memory tasks.
**Contents**
- [Memory Performance & Accuracy](#memory-performance--accuracy)
- [Quick Start](#quick-start) — [server](#1-start-a-server) · [clients](#2-connect-a-client) · [platforms](#supported-platforms) · [embedded](#python-embedded-no-server-required)
- [Adding Hindsight to Your Agent](#adding-hindsight-to-your-agent) — [LLM Wrapper](#llm-wrapper-2-lines-of-code) · [integrations](#integrations) · [coding agents](#coding-agents) · [MCP](#mcp-server)
- [Core Concepts](#core-concepts) — [memory types](#memory-types) · [retain / recall / reflect](#the-three-operations) · [observations](#observations) · [mental models & knowledge pages](#mental-models--knowledge-pages) · [banks](#memory-banks)
- [Use Cases](#use-cases)
- [Running in Production](#running-in-production)
- [Resources](#resources)
---
## Memory Performance & Accuracy
Hindsight is the most accurate agent memory system ever tested according to benchmark performance. It has achieved state-of-the-art performance on the LongMemEval benchmark, widely used to assess memory system performance across a variety of conversational AI scenarios. The current reported performance of Hindsight and other agent memory solutions as of January 2026 is shown here:
![Overview](./hindsight-docs/static/img/hindsight-benchmarks.png)
> Live, continuously updated results — including per-model accuracy, latency and cost — are published at [benchmarks.hindsight.vectorize.io](https://benchmarks.hindsight.vectorize.io/).
The benchmark performance data for Hindsight has been independently reproduced by research collaborators at the Virginia Tech [Sanghani Center for Artificial Intelligence and Data Analytics](https://sanghani.cs.vt.edu/) and The Washington Post. Other scores are self-reported by software vendors.
Hindsight is being used in production at Fortune 500 enterprises and by a growing number of AI startups.
## Adding Hindsight to Your AI Agents
The easiest way to use Hindsight with an existing agent is with the LLM Wrapper. You can add memory to your agent with 2 lines of code. That will swap your current LLM client out with the Hindsight wrapper. After that, memories will be stored and retrieved automatically as you make LLM calls.
If you need more control over how and when your agent stores and recalls memories, there's also a simple API you can integrate with using the SDKs or directly via HTTP.
![Hindsight Banner](./hindsight-docs/static/img/migration-code.png)
Hindsight is being used in production at Fortune 500 enterprises and by a growing number of AI startups.
---
@@ -53,10 +59,11 @@ If you need more control over how and when your agent stores and recalls memorie
---
## Quick Start
### Docker (recommended)
### 1. Start a server
#### Docker (recommended)
```bash
export OPENAI_API_KEY=sk-xxx
@@ -70,31 +77,52 @@ docker run -it --pull always --name hindsight --restart unless-stopped -p 8888:8
>API: http://localhost:8888
>UI: http://localhost:9999
You can modify the LLM provider by setting `HINDSIGHT_API_LLM_PROVIDER`. Valid options are `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `lmstudio`, `minimax`, and `atlas` ([Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=hindsight)). The documentation provides more details on [supported models](https://hindsight.vectorize.io/developer/models).
Hindsight works with **25+ LLM providers** via `HINDSIGHT_API_LLM_PROVIDER` — hosted (`openai`, `anthropic`, `gemini`, `groq`, `bedrock`, `vertexai`, `minimax`, `deepseek`, `atlas`, …), fully local (`ollama`, `lmstudio`, `llamacpp`), any OpenAI-compatible endpoint, and gateways (`litellm`, `litellmrouter`) that reach the rest. Existing subscriptions work too: `openai-codex` (ChatGPT Plus/Pro) and `claude-code` (Claude Pro/Max) need no API key. See [supported models](https://hindsight.vectorize.io/developer/models).
### Docker (external PostgreSQL)
#### Docker (external PostgreSQL)
```bash
export OPENAI_API_KEY=sk-xxx
export HINDSIGHT_DB_PASSWORD=choose-a-password
cd docker/docker-compose
docker compose up
docker compose up
```
> Oracle AI Database is also supported for enterprise deployments with full feature parity. See the [storage documentation](https://hindsight.vectorize.io/developer/storage) for details.
>API: http://localhost:8888
>UI: http://localhost:9999
### Client
#### Bare metal (pip)
```bash
pip install hindsight-client -U
# or
npm install @vectorize-io/hindsight-client
pip install hindsight-api
export HINDSIGHT_API_LLM_API_KEY=sk-xxx
hindsight-api
```
#### Kubernetes (Helm)
```bash
helm install hindsight oci://ghcr.io/vectorize-io/charts/hindsight \
--set api.llm.provider=openai \
--set api.llm.apiKey=sk-xxx \
--set postgresql.enabled=true
```
#### Managed (no server)
[Hindsight Cloud](https://vectorize.io/pricing) is the hosted option: managed infrastructure that scales automatically, plus a dashboard, backups, team collaboration and a 99.9% uptime SLA. Billing is usage-based with free credits to start — no fixed monthly or per-seat fee. Point any client at `https://api.hindsight.vectorize.io` with your API key and skip the deployment entirely.
[Compare self-hosted, Cloud and Enterprise →](https://vectorize.io/pricing) · [Sign up →](https://ui.hindsight.vectorize.io/signup)
All options, including Windows and air-gapped setups, are covered in the [installation guide](https://hindsight.vectorize.io/developer/installation).
### 2. Connect a client
```bash
pip install hindsight-client -U # Python
npm install @vectorize-io/hindsight-client # Node.js / TypeScript
go get github.com/vectorize-io/hindsight/hindsight-clients/go # Go
curl -fsSL https://hindsight.vectorize.io/get-cli | bash # CLI
```
#### Python
@@ -116,10 +144,6 @@ client.reflect(bank_id="my-bank", query="Tell me about Alice")
#### Node.js / TypeScript
```bash
npm install @vectorize-io/hindsight-client
```
```javascript
const { HindsightClient } = require('@vectorize-io/hindsight-client');
@@ -135,6 +159,18 @@ const main = async () => {
main();
```
Full reference: [Python](https://hindsight.vectorize.io/sdks/python) · [Node.js](https://hindsight.vectorize.io/sdks/nodejs) · [Go](https://hindsight.vectorize.io/sdks/go) · [CLI](https://hindsight.vectorize.io/sdks/cli) · [REST API](https://hindsight.vectorize.io/api-reference)
### Supported Platforms
| Platform | Docker | Bare Metal (pip) | Embedded DB (pg0) |
|----------|--------|------------------|--------------------|
| **Linux** (x86_64, ARM64) | ✅ | ✅ | ✅ |
| **macOS** (Apple Silicon / arm64) | ✅ | ✅ | ✅ |
| **macOS** (Intel / x86_64) | ✅ | ⚠️ | ✅ |
| **Windows** (x86_64) | ✅ | ✅ | ✅ |
⚠️ Intel Macs: use `hindsight-all-slim` — see the [installation guide](https://hindsight.vectorize.io/developer/installation#supported-platforms) for details.
### Python Embedded (no server required)
@@ -150,7 +186,7 @@ from hindsight import HindsightServer, HindsightClient
with HindsightServer(
llm_provider="openai",
llm_model="gpt-5-mini",
llm_model="gpt-5-mini",
llm_api_key=os.environ["OPENAI_API_KEY"]
) as server:
client = HindsightClient(base_url=server.url)
@@ -158,12 +194,182 @@ with HindsightServer(
results = client.recall(bank_id="my-bank", query="Where does Alice work?")
```
A [Node.js equivalent](https://hindsight.vectorize.io/sdks/hindsight-all-npm) and a [daemon CLI](https://hindsight.vectorize.io/sdks/embed) are also available.
---
## Adding Hindsight to Your Agent
### LLM Wrapper (2 lines of code)
The easiest way to add memory to an existing agent is the LLM Wrapper. Swap your LLM client for a wrapped one — memories are then stored and retrieved automatically on every call, with no other changes to your code.
```bash
pip install hindsight-litellm
```
```python
from openai import OpenAI
from hindsight_litellm import wrap_openai
# Wrap your existing LLM client and you're done.
# Defaults to Hindsight Cloud; pass hindsight_api_url for a self-hosted server.
client = wrap_openai(
OpenAI(),
bank_id="user-123",
hindsight_api_url="http://localhost:8888",
)
# Hindsight recalls relevant memories before the call
# and retains the conversation after it.
response = client.chat.completions.create(
model="gpt-5-mini",
messages=[{"role": "user", "content": "What do you know about me?"}],
)
```
`wrap_anthropic()` does the same for the Anthropic SDK, and every setting — bank, recall budget, fact types, reflect instead of recall — can be overridden per call with `hindsight_*` kwargs. LiteLLM sits underneath, so the same integration covers **100+ models**. See the [LiteLLM integration](https://hindsight.vectorize.io/sdks/integrations/litellm).
If you need explicit control over *when* memories are stored and recalled, use the [SDKs or REST API](#2-connect-a-client) directly instead.
### Integrations
**60+ integrations** — most need no code changes.
| | |
|---|---|
| **Coding agents** | [Claude Code](https://hindsight.vectorize.io/sdks/integrations/claude-code) · [Codex](https://hindsight.vectorize.io/sdks/integrations/codex) · [Cursor](https://hindsight.vectorize.io/sdks/integrations/cursor) · [GitHub Copilot](https://hindsight.vectorize.io/sdks/integrations/github-copilot) · [opencode](https://hindsight.vectorize.io/sdks/integrations/opencode) · [Cline](https://hindsight.vectorize.io/sdks/integrations/cline) · [Aider](https://hindsight.vectorize.io/sdks/integrations/aider) · [Zed](https://hindsight.vectorize.io/sdks/integrations/zed) · [Continue](https://hindsight.vectorize.io/sdks/integrations/continue) · [Roo Code](https://hindsight.vectorize.io/sdks/integrations/roo-code) · [OpenHands](https://hindsight.vectorize.io/sdks/integrations/openhands) |
| **Agent frameworks** | [LangGraph / LangChain](https://hindsight.vectorize.io/sdks/integrations/langgraph) · [LlamaIndex](https://hindsight.vectorize.io/sdks/integrations/llamaindex) · [CrewAI](https://hindsight.vectorize.io/sdks/integrations/crewai) · [Pydantic AI](https://hindsight.vectorize.io/sdks/integrations/pydantic-ai) · [OpenAI Agents SDK](https://hindsight.vectorize.io/sdks/integrations/openai-agents) · [Google ADK](https://hindsight.vectorize.io/sdks/integrations/google-adk) · [Agno](https://hindsight.vectorize.io/sdks/integrations/agno) · [Strands](https://hindsight.vectorize.io/sdks/integrations/strands) · [AutoGen](https://hindsight.vectorize.io/sdks/integrations/autogen) · [Microsoft Agent Framework](https://hindsight.vectorize.io/sdks/integrations/agent-framework) · [Vercel AI SDK](https://hindsight.vectorize.io/sdks/integrations/ai-sdk) · [Haystack](https://hindsight.vectorize.io/sdks/integrations/haystack) |
| **No-code / low-code** | [n8n](https://hindsight.vectorize.io/sdks/integrations/n8n) · [Zapier](https://hindsight.vectorize.io/sdks/integrations/zapier) · [Dify](https://hindsight.vectorize.io/sdks/integrations/dify) · [Flowise](https://hindsight.vectorize.io/sdks/integrations/flowise) |
| **Apps & tools** | [ChatGPT](https://hindsight.vectorize.io/sdks/integrations/chatgpt) · [Perplexity](https://hindsight.vectorize.io/sdks/integrations/perplexity) · [Obsidian](https://hindsight.vectorize.io/sdks/integrations/obsidian) · [Pipecat](https://hindsight.vectorize.io/sdks/integrations/pipecat) · [Vapi](https://hindsight.vectorize.io/sdks/integrations/vapi) |
👉 [**Browse all integrations**](https://hindsight.vectorize.io/integrations)
### Coding Agents
One package gives CLI coding agents long-term project memory: a per-repo bank built automatically from git history and past sessions, injected into the agent as it starts working, plus curated knowledge pages covering architecture, conventions and in-flight work.
```bash
npx @vectorize-io/hindsight-coding-agents install all # every detected agent, wired natively
npx @vectorize-io/hindsight-coding-agents install claude-code # or just one
```
Supports Claude Code, Codex CLI, Cursor CLI, GitHub Copilot CLI, opencode, Kilo CLI, Cline CLI, Antigravity CLI, Devin CLI, Prime Agent, Grok Build and DeepSeek Harness. Ingestion is automatic — there is no setup command. See the [coding agents integration](https://hindsight.vectorize.io/sdks/integrations/coding-agents).
### MCP Server
Every server ships a built-in [Model Context Protocol](https://modelcontextprotocol.io/) endpoint, one per bank, enabled by default:
```
http://localhost:8888/mcp/{bank_id}/
```
Point any MCP client at it to expose retain, recall and reflect as tools. See the [MCP server docs](https://hindsight.vectorize.io/developer/mcp-server).
---
## Core Concepts
![Overview](./hindsight-docs/static/img/hindsight-overview.webp)
### Memory Types
Most agent memory implementations rely on basic vector search or sometimes use a knowledge graph. Hindsight uses biomimetic data structures to organize agent memories in a way that is more like how human memory works:
- **World facts:** facts about the world ("The stove gets hot")
- **Experiences:** the agent's own experiences ("I touched the stove and it really hurt")
- **Observations:** consolidated, evidence-backed beliefs formed from many memories
- **Mental models:** learned understanding of the agent's world, synthesized from observations and facts
Memories live in **banks**. When memories are added, they are pushed into either the world facts or the experiences pathway, then represented as a combination of entities, relationships, and time series with sparse/dense vector representations to aid in later recall.
### The Three Operations
#### Retain
The `retain` operation is used to push new memories into Hindsight. It tells Hindsight to _retain_ the information you pass in as an input.
```python
client.retain(
bank_id="my-bank",
content="Alice got promoted to senior engineer",
context="career update",
timestamp="2025-06-15T10:00:00Z",
)
```
Behind the scenes, retain uses an LLM to extract key facts, temporal data, entities, and relationships. It passes these through a normalization process to transform extracted data into canonical entities, time series, and search indexes along with metadata. These representations create the pathways for accurate memory retrieval in the recall and reflect operations.
![Retain Operation](hindsight-docs/static/img/retain-operation.webp)
[Retain docs →](https://hindsight.vectorize.io/developer/retain)
#### Recall
The recall operation is used to retrieve memories. These memories can come from any of the memory types (world, experiences, etc.)
```python
client.recall(bank_id="my-bank", query="What does Alice do?")
client.recall(bank_id="my-bank", query="What happened in June?") # temporal
```
Recall performs 4 retrieval strategies in parallel:
- Semantic: Vector similarity
- Keyword: BM25 exact matching
- Graph: Entity/temporal/causal links
- Temporal: Time range filtering
![Recall Operation](hindsight-docs/static/img/recall-operation.webp)
The individual results are merged, ordered by relevance using reciprocal rank fusion and a cross-encoder reranking model, then trimmed as needed to fit within the token limit.
[Recall docs →](https://hindsight.vectorize.io/developer/retrieval)
#### Reflect
The reflect operation performs a more thorough analysis of existing memories. This allows the agent to form new connections between memories and build a more thorough understanding of its world — or to answer a question that needs deep thinking rather than lookup.
```python
client.reflect(bank_id="my-bank", query="What should I know about Alice?")
```
For example, reflect supports use cases such as:
- An **AI Project Manager** reflecting on what risks need to be mitigated on a project.
- A **Sales Agent** reflecting on why certain outreach messages have gotten responses while others haven't.
- A **Support Agent** reflecting on opportunities where customers have questions not answered by current product documentation.
![Reflect Operation](hindsight-docs/static/img/reflect-operation.webp)
[Reflect docs →](https://hindsight.vectorize.io/developer/reflect)
### Observations
Retained facts don't stay a flat pile. In the background, Hindsight consolidates related facts into **observations** — deduplicated beliefs the bank has built up over time. Each observation keeps its supporting evidence with exact quotes and a proof count, and is *refined* rather than overwritten when new evidence arrives, so new information strengthens, weakens or extends an existing belief instead of silently replacing it.
[Observations docs →](https://hindsight.vectorize.io/developer/observations)
### Mental Models & Knowledge Pages
A **mental model** is a standing answer to a question about a bank ("What are this user's preferences?"). You define the question once; Hindsight writes the answer, stores it, and rewrites it in the background as the bank learns more. Reading one is a database read — no retrieval, no LLM call — so an agent can boot with a page of settled knowledge instead of rediscovering it every session.
**Knowledge pages** are mental models with the mechanics hidden: living documents a bank writes about itself, organized in folders like a wiki, searchable, and projectable onto disk as ordinary markdown. Supply a name and a question; every other decision is a default you can override.
[Mental models →](https://hindsight.vectorize.io/developer/mental-models) · [Knowledge pages →](https://hindsight.vectorize.io/developer/knowledge-pages)
### Memory Banks
A **bank** is an isolated memory store — one "brain" for one user, agent, or project. Isolation is strict: no cross-bank leakage. Banks carry background context and **disposition traits** (skepticism, literalism, empathy) that shape how reflect reasons over their memories, and can be created from declarative [bank templates](https://hindsight.vectorize.io/developer/api/bank-templates).
Two more things worth knowing:
- **Multilingual by default.** Input language is detected and preserved end to end — facts stay in their original language and entities keep their native script (张伟 stays 张伟, not "Zhang Wei"). [Docs →](https://hindsight.vectorize.io/developer/multilingual)
- **Memory Defense.** An opt-in, per-bank policy that scans every retain for secrets and PII against 45 patterns and either redacts the match (`[REDACTED:github_token]`) or blocks the item before it reaches storage. [Docs →](https://hindsight.vectorize.io/developer/memory-defense)
---
## Use Cases
Hindsight is built to support conversational AI agents as well as agents that are intended to perform tasks autonomously. The ideal use case for Hindsight are agents that require a blend of these features such as AI employees that need to handle open-ended tasks, change behavior based on user feedback, and learn to perform complex tasks to automate work at a level that approximates a human work. Hindsight can be used with simple AI workflows like those built with n8n and other similar tools, but may be overkill for such applications.
### Per-User Memories and Chat History
@@ -176,141 +382,46 @@ The requirements for this use case usually look something like this:
<video src="https://github.com/user-attachments/assets/4805e8e1-e7d1-47c6-a4f8-2344a5ec8906" controls></video>
Satisfying these requirements in Hindsight is straightforward. When new user inputs and tool calls are ingested into Hindsight using the retain operation, custom metadata can be used to enrich the new memories. Metadata provides a convenient way to isolate memories that need to be restricted to a given user. Once these are fed into the retain operation, any raw memories and mental models that get created can be filtered when retrieving relevant memories.
Satisfying these requirements in Hindsight is straightforward. When new user inputs and tool calls are ingested into Hindsight using the retain operation, custom metadata can be used to enrich the new memories. Metadata provides a convenient way to isolate memories that need to be restricted to a given user. Once these are fed into the retain operation, any raw memories and mental models that get created can be filtered when retrieving relevant memories.
![Per-User Memories](./hindsight-docs/static/img/per-user-memory-howto.png)
More patterns in the [Cookbook](https://hindsight.vectorize.io/cookbook) and [Best Practices](https://hindsight.vectorize.io/best-practices).
---
## Architecture & Operations
## Running in Production
![Overview](./hindsight-docs/static/img/hindsight-overview.webp)
Most agent memory implementations rely on basic vector search or sometimes use a knowledge graph. Hindsight uses biomimetic data structures to organize agent memories in a way that is more like how human memory works:
- **World:** Facts about the world ("The stove gets hot")
- **Experiences:** Agent's own experiences ("I touched the stove and it really hurt")
- **Mental Models:** Learned understanding of the agent's world formed by reflecting on raw memories and experiences.
Memories in Hindsight are stored in banks (i.e. memory banks). When memories are added to Hindsight, they are pushed into either the world facts or experiences memory pathway. They are then represented as a combination of entities, relationships, and time series with sparse/dense vector representations to aid in later recall.
Hindsight provides three simple methods to interact with the system:
- **Retain:** Provide information to Hindsight that you want it to remember
- **Recall:** Retrieve memories from Hindsight
- **Reflect:** Reflect on memories and experiences to generate new observations and insights from existing memories.
### Retain
The `retain` operation is used to push new memories into Hindsight. It tells Hindsight to _retain_ the information you pass in as an input.
```python
from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
# Simple
client.retain(
bank_id="my-bank",
content="Alice works at Google as a software engineer"
)
# With context and timestamp
client.retain(
bank_id="my-bank",
content="Alice got promoted to senior engineer",
context="career update",
timestamp="2025-06-15T10:00:00Z"
)
```
Behind the scenes, the retain operation uses an LLM to extract key facts, temporal data, entities, and relationships. It passes these through a normalization process to transform extracted data into canonical entities, time series, and search indexes along with metadata. These representations create the pathways for accurate memory retrieval in the recall and reflect operations.
![Retain Operation](hindsight-docs/static/img/retain-operation.webp)
### Recall
The recall operation is used to retrieve memories. These memories can come from any of the memory types (world, experiences, etc.)
```python
from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
# Simple
client.recall(bank_id="my-bank", query="What does Alice do?")
# Temporal
client.recall(bank_id="my-bank", query="What happened in June?")
```
Recall performs 4 retrieval strategies in parallel:
- Semantic: Vector similarity
- Keyword: BM25 exact matching
- Graph: Entity/temporal/causal links
- Temporal: Time range filtering
![Recall Operation](hindsight-docs/static/img/recall-operation.webp)
The individual results from the retrievals are merged, then ordered by relevance using reciprocal rank fusion and a cross-encoder reranking model.
The final output is trimmed as needed to fit within the token limit.
### Reflect
The reflect operation is used to perform a more thorough analysis of existing memories. This allows the agent to form new connections between memories and build a more thorough understanding of its world.
For example, the `reflect` operation can be used to support use cases such as:
- An **AI Project Manager** reflecting on what risks need to be mitigated on a project.
- A **Sales Agent** reflecting on why certain outreach messages have gotten responses while others haven't.
- A **Support Agent** reflecting on opportunities where customers have questions not answered by current product documentation.
The `reflect` operation can also be used to handle on-demand question answering or analysis which require more deep thinking.
```python
from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
client.reflect(bank_id="my-bank", query="What should I know about Alice?")
```
![Reflect Operation](hindsight-docs/static/img/reflect-operation.webp)
| | |
|---|---|
| **Storage** | PostgreSQL + pgvector, or Oracle AI Database 23ai with full feature parity — [storage](https://hindsight.vectorize.io/developer/storage) |
| **Configuration** | Hierarchical: global env vars → per-tenant → per-bank — [configuration](https://hindsight.vectorize.io/developer/configuration) |
| **Monitoring** | Prometheus metrics and dashboards for LLM calls, tokens and latency — [monitoring](https://hindsight.vectorize.io/developer/monitoring) |
| **Operations** | Admin CLI for migrations, bank repair and stuck operations — [admin CLI](https://hindsight.vectorize.io/developer/admin-cli) |
| **Events** | Webhooks for retain, consolidation and refresh lifecycle events — [webhooks](https://hindsight.vectorize.io/developer/api/webhooks) |
| **Extensibility** | Tenant, auth and storage extension points — [extensions](https://hindsight.vectorize.io/developer/extensions) |
| **Managed** | Skip all of it with [Hindsight Cloud](https://vectorize.io/pricing) — managed, usage-based, 99.9% uptime SLA |
---
## Resources
**Documentation:**
- [https://hindsight.vectorize.io](https://hindsight.vectorize.io)
**Documentation:**
- [Docs](https://hindsight.vectorize.io) · [FAQ](https://hindsight.vectorize.io/faq) · [Best Practices](https://hindsight.vectorize.io/best-practices) · [Cookbook](https://hindsight.vectorize.io/cookbook) · [Blog](https://hindsight.vectorize.io/blog)
- [Paper](https://arxiv.org/abs/2512.12818) · [Benchmarks](https://benchmarks.hindsight.vectorize.io/) · [RAG vs Memory](https://hindsight.vectorize.io/developer/rag-vs-hindsight)
**Clients:**
- [Python](http://hindsight.vectorize.io/sdks/python)
- [Node.js](http://hindsight.vectorize.io/sdks/nodejs)
- [REST API](https://hindsight.vectorize.io/api-reference)
- [CLI](https://hindsight.vectorize.io/sdks/cli)
- [Python](https://hindsight.vectorize.io/sdks/python) · [Node.js](https://hindsight.vectorize.io/sdks/nodejs) · [Go](https://hindsight.vectorize.io/sdks/go) · [CLI](https://hindsight.vectorize.io/sdks/cli) · [REST API](https://hindsight.vectorize.io/api-reference)
**Community:**
- [Slack](https://join.slack.com/t/hindsight-space/shared_invite/zt-3nhbm4w29-LeSJ5Ixi6j8PdiYOCPlOgg)
- [GitHub Issues](https://github.com/vectorize-io/hindsight/issues)
---
## Star History
[![Star history](https://raw.githubusercontent.com/vectorize-io/hindsight/main/.github/star-history/chart.svg)](https://github.com/vectorize-io/hindsight/stargazers)
---
## Supported Platforms
| Platform | Docker | Bare Metal (pip) | Embedded DB (pg0) |
|----------|--------|------------------|--------------------|
| **Linux** (x86_64, ARM64) | ✅ | ✅ | ✅ |
| **macOS** (Apple Silicon / arm64) | ✅ | ✅ | ✅ |
| **macOS** (Intel / x86_64) | ✅ | ⚠️ | ✅ |
| **Windows** (x86_64) | ✅ | ✅ | ✅ |
⚠️ Intel Macs: use `hindsight-all-slim` — see the [installation guide](https://hindsight.vectorize.io/developer/installation#supported-platforms) for details.
---
+55 -23
View File
@@ -64,15 +64,26 @@ class HindsightEmbedded:
- create_directive(), list_directives(), etc.
- And all async variants (aretain, arecall, areflect, etc.)
Only the settings you pass explicitly are forwarded to the daemon. Anything
left at its default is resolved by the daemon instead, in this order: the
profile's .env file, then the parent process environment, then the daemon's
own default. That is what lets a client constructed without credentials run
against a profile (or a shell) that already has them configured, rather than
overwriting them with placeholders (#3253).
Args:
profile: Profile name for data isolation (default: "default")
llm_provider: LLM provider ("groq", "openai", "ollama", "gemini", "anthropic", "lmstudio")
llm_api_key: API key for the LLM provider
llm_model: Model name to use
llm_provider: LLM provider ("groq", "openai", "ollama", "gemini", "anthropic",
"lmstudio"). Omit to inherit; the server default is "openai".
llm_api_key: API key for the LLM provider. Omit to inherit; pass "" to
explicitly run without a key (local services that need no auth).
llm_model: Model name to use. Omit to inherit; the server picks a default
for the resolved provider.
llm_base_url: Optional custom base URL for LLM API
database_url: Optional database URL override (default: profile-specific pg0)
idle_timeout: Seconds before daemon auto-exits when idle (default: 0, disabled)
log_level: Daemon log level (default: "info")
idle_timeout: Seconds before daemon auto-exits when idle. Omit to inherit
(daemon default: 0, disabled).
log_level: Daemon log level. Omit to inherit (daemon default: "info").
ui: Whether to start the control plane web UI alongside the daemon (default: False)
ui_port: Port for the UI. Defaults to daemon_port + 10000.
ui_hostname: Hostname to bind the UI to. Defaults to "0.0.0.0".
@@ -81,13 +92,13 @@ class HindsightEmbedded:
def __init__(
self,
profile: str = "default",
llm_provider: str = "groq",
llm_api_key: str = "",
llm_model: str = "openai/gpt-oss-120b",
llm_provider: Optional[str] = None,
llm_api_key: Optional[str] = None,
llm_model: Optional[str] = None,
llm_base_url: Optional[str] = None,
database_url: Optional[str] = None,
idle_timeout: int = 0,
log_level: str = "info",
idle_timeout: Optional[int] = None,
log_level: Optional[str] = None,
ui: bool = False,
ui_port: Optional[int] = None,
ui_hostname: str = "0.0.0.0",
@@ -95,29 +106,50 @@ class HindsightEmbedded:
"""
Initialize the embedded client (daemon starts on first use).
Every LLM/daemon setting left as None is omitted from the daemon config so
the daemon resolves it from the profile .env, then the parent environment,
then its own default.
Args:
profile: Profile name for data isolation
llm_provider: LLM provider
llm_api_key: API key for the LLM provider
llm_model: Model name to use
llm_provider: LLM provider. Omit to inherit.
llm_api_key: API key for the LLM provider. Omit to inherit; pass "" to
explicitly run without a key.
llm_model: Model name to use. Omit to inherit.
llm_base_url: Optional custom base URL for LLM API
database_url: Optional database URL override
idle_timeout: Seconds before daemon auto-exits when idle (0 = disabled)
log_level: Daemon log level
idle_timeout: Seconds before daemon auto-exits when idle (0 = disabled).
Omit to inherit.
log_level: Daemon log level. Omit to inherit.
ui: Whether to start the control plane web UI alongside the daemon
ui_port: Port for the UI (defaults to daemon_port + 10000)
ui_hostname: Hostname to bind the UI to (defaults to "0.0.0.0")
"""
self.profile = profile
# Build config dict for daemon (matches CLI format)
self.config = {
"HINDSIGHT_API_LLM_PROVIDER": llm_provider,
"HINDSIGHT_API_LLM_API_KEY": llm_api_key,
"HINDSIGHT_API_LLM_MODEL": llm_model,
"HINDSIGHT_API_LOG_LEVEL": log_level,
"HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT": str(idle_timeout),
}
# Build the config dict for the daemon (matches CLI format), omitting
# every setting the caller did not specify. An omitted key is inherited
# by the daemon from the profile .env / parent environment; sending a
# placeholder instead would overwrite it, and _register_profile would
# then persist that placeholder into the profile's .env file (#3253).
# An explicit "" is still an override — that is how a local LLM service
# with no authentication clears an inherited API key.
self.config: dict[str, str] = {}
if llm_provider is not None:
self.config["HINDSIGHT_API_LLM_PROVIDER"] = llm_provider
if llm_api_key is not None:
self.config["HINDSIGHT_API_LLM_API_KEY"] = llm_api_key
if llm_model is not None:
self.config["HINDSIGHT_API_LLM_MODEL"] = llm_model
if log_level is not None:
self.config["HINDSIGHT_API_LOG_LEVEL"] = log_level
if idle_timeout is not None:
self.config["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] = str(idle_timeout)
if llm_base_url:
self.config["HINDSIGHT_API_LLM_BASE_URL"] = llm_base_url
+181
View File
@@ -0,0 +1,181 @@
"""Configuration forwarding rules for HindsightEmbedded.
Regression coverage for #3253: a setting the caller does not pass must be left
out of the daemon config, so the daemon can resolve it from the profile's .env
file or the parent environment instead of receiving a client-side placeholder
that overwrites it — and that the daemon then persists back into the profile.
"""
import json
from unittest.mock import MagicMock, patch
import pytest
from hindsight import HindsightEmbedded
from hindsight_embed.daemon_embed_manager import DaemonEmbedManager
LLM_PROVIDER = "HINDSIGHT_API_LLM_PROVIDER"
LLM_API_KEY = "HINDSIGHT_API_LLM_API_KEY"
LLM_MODEL = "HINDSIGHT_API_LLM_MODEL"
LOG_LEVEL = "HINDSIGHT_API_LOG_LEVEL"
IDLE_TIMEOUT = "HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"
@pytest.fixture
def temp_home(tmp_path, monkeypatch):
"""Isolate HOME so profile .env files never touch the real user profile.
USERPROFILE is set as well because Path.home() consults it on Windows.
"""
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
return home
def _write_profile(home, name, port, env_contents=None):
"""Create a registered profile, optionally with a pre-populated .env file."""
profile_dir = home / ".hindsight" / "profiles"
profile_dir.mkdir(parents=True, exist_ok=True)
(profile_dir / "metadata.json").write_text(
json.dumps(
{
"version": 1,
"profiles": {
name: {
"port": port,
"created_at": "2024-01-01T00:00:00+00:00",
"last_used": "2024-01-01T00:00:00+00:00",
}
},
}
)
)
env_path = profile_dir / f"{name}.env"
if env_contents is not None:
env_path.write_text(env_contents)
return env_path
def _daemon_env(client):
"""Run the real daemon start path with Popen stubbed, returning the child env.
Asserting on client.config alone would not catch a regression in how the
embed manager merges that config with the profile and the parent
environment, which is where the reported bug actually surfaced.
"""
manager = DaemonEmbedManager()
captured: dict[str, dict[str, str]] = {}
spawned = [False]
def fake_popen(cmd, env, **kwargs):
captured["env"] = env
spawned[0] = True
process = MagicMock()
process.pid = 12345
return process
with (
patch("hindsight_embed.daemon_embed_manager.subprocess.Popen", side_effect=fake_popen),
patch("hindsight_embed.daemon_embed_manager.time.sleep"),
patch.object(manager, "_clear_port", return_value=True),
patch.object(manager, "_find_api_command", return_value=["hindsight-api"]),
patch.object(manager, "is_running", side_effect=lambda profile="": spawned[0]),
patch("hindsight_embed.daemon_embed_manager.platform.system", return_value="Linux"),
):
assert manager.ensure_running(client.config, client.profile)
return captured["env"]
def test_nothing_is_forwarded_when_nothing_is_specified(temp_home):
assert HindsightEmbedded(profile="test").config == {}
def test_explicitly_passed_settings_are_forwarded(temp_home):
client = HindsightEmbedded(
profile="test",
llm_provider="openai",
llm_api_key="sk-real",
llm_model="gpt-4o-mini",
log_level="debug",
idle_timeout=300,
)
assert client.config == {
LLM_PROVIDER: "openai",
LLM_API_KEY: "sk-real",
LLM_MODEL: "gpt-4o-mini",
LOG_LEVEL: "debug",
IDLE_TIMEOUT: "300",
}
def test_empty_api_key_is_forwarded_as_an_override(temp_home):
"""An empty string is an explicit choice, not an omission.
Local LLM services that need no authentication rely on it to clear a key
inherited from the environment.
"""
assert HindsightEmbedded(profile="test", llm_api_key="").config[LLM_API_KEY] == ""
def test_idle_timeout_zero_is_forwarded(temp_home):
"""0 is falsy but meaningful ("never auto-exit"), so it must survive."""
assert HindsightEmbedded(profile="test", idle_timeout=0).config[IDLE_TIMEOUT] == "0"
def test_omitted_key_inherits_the_parent_environment(temp_home, monkeypatch):
monkeypatch.setenv(LLM_API_KEY, "sk-parent")
_write_profile(temp_home, "inherit-env", 9871)
env = _daemon_env(HindsightEmbedded(profile="inherit-env", llm_provider="openai"))
assert env[LLM_API_KEY] == "sk-parent"
def test_omitted_settings_inherit_the_profile_env(temp_home, monkeypatch):
for var in (LLM_PROVIDER, LLM_API_KEY, LLM_MODEL):
monkeypatch.delenv(var, raising=False)
env_path = _write_profile(
temp_home,
"prod",
9872,
"HINDSIGHT_API_LLM_PROVIDER=anthropic\n"
"HINDSIGHT_API_LLM_MODEL=claude-sonnet-4-20250514\n"
"HINDSIGHT_API_LLM_API_KEY=sk-ant-prod\n",
)
env = _daemon_env(HindsightEmbedded(profile="prod"))
assert env[LLM_PROVIDER] == "anthropic"
assert env[LLM_MODEL] == "claude-sonnet-4-20250514"
assert env[LLM_API_KEY] == "sk-ant-prod"
# A successful start rewrites the profile's .env; it must not come back with
# client-side placeholders in place of the configured values.
persisted = env_path.read_text()
assert "HINDSIGHT_API_LLM_PROVIDER=anthropic" in persisted
assert "HINDSIGHT_API_LLM_MODEL=claude-sonnet-4-20250514" in persisted
assert "HINDSIGHT_API_LLM_API_KEY=sk-ant-prod" in persisted
def test_explicit_empty_key_overrides_the_parent_environment(temp_home, monkeypatch):
monkeypatch.setenv(LLM_API_KEY, "sk-parent")
_write_profile(temp_home, "no-auth", 9873)
env = _daemon_env(
HindsightEmbedded(profile="no-auth", llm_provider="lmstudio", llm_api_key="")
)
assert env[LLM_API_KEY] == ""
def test_explicit_settings_still_win_over_the_profile(temp_home, monkeypatch):
monkeypatch.delenv(LLM_PROVIDER, raising=False)
_write_profile(temp_home, "override", 9874, "HINDSIGHT_API_LLM_PROVIDER=anthropic\n")
env = _daemon_env(HindsightEmbedded(profile="override", llm_provider="openai"))
assert env[LLM_PROVIDER] == "openai"
@@ -47,13 +47,64 @@ _INDEX_TYPE_KEYWORDS = {
"scann": "scann",
}
# Ceiling on how many tuples one resumed ANN scan may visit (hnsw.max_scan_tuples).
# Only iterative scans consult it, and it is approximate — the initial round is not
# counted. pgvector defaults to 20000; this is deliberately lower.
#
# The filters that thin a semantic arm (the similarity floor, tags, date ranges) are
# applied *after* the index scan, so a selective query resumes repeatedly to fill its
# LIMIT. Unbounded, that turns the cheapest queries today into the most expensive:
# ~20x the standing batch is enough to fill even a large recall budget on an
# unfiltered query, and caps the pathological filtered case at a scan that returns
# short — which is exactly what those queries did before iterative scans were on.
# The GUCs that make a scan resumable — dropped wholesale when the operator turns the
# behaviour off, so a connection is left exactly as it was before it existed (and a
# pgvector too old to define them is never sent them either).
_ITERATIVE_SCAN_GUCS = frozenset({"hnsw.iterative_scan", "hnsw.max_scan_tuples"})
def iterative_scan_enabled() -> bool:
"""Whether ANN scans may resume to satisfy a query's LIMIT.
Turning it off restores the previous depth exactly: a scan stops when its first
candidate list drains, so no recall retrieves more rows than that list holds,
whatever its budget.
Resolved through the config object rather than read from the environment, so a
value set any other way — a CLI override applied with dataclasses.replace, a
programmatically built config — is honoured, and the parsing and validation live
in one place. Imported inside the function because config imports this module.
"""
from .config import get_config
return get_config().ann_iterative_scan
def ann_max_scan_tuples() -> int:
"""Ceiling on tuples one resumed scan may visit (hnsw.max_scan_tuples).
This is the knob that governs the cost of the behaviour. It bounds the CPU a
selective query can spend resuming, and with it the scan's memory — pgvector
otherwise caps that at ``work_mem * hnsw.scan_mem_multiplier``, but at this
default the memory ceiling is never approached: squeezing work_mem to 256kB
changes neither the rows returned nor the latency.
Approximate, and the initial scan is not counted, so even 1 leaves intact the
depth a query had before scans could resume.
"""
from .config import get_config
return get_config().ann_max_scan_tuples
# Per-backend ANN search-time tuning GUCs. Each entry is a tuple of
# (guc_name, value) pairs the caller can apply with SET or SET LOCAL.
#
# - pgvector exposes hnsw.ef_search. The 60 / 200 pair is unchanged from the
# pre-dispatcher code (internal benchmarks tuned around our embedding count
# and recall floor; see the link_utils / pool init call sites for the
# latency-vs-recall framing).
# latency-vs-recall framing). With iterative scans on (below) the ef value is a
# batch size rather than a ceiling, so a query's own LIMIT decides its depth.
# - vchord exposes vchordrq.probes, but its shape must match the index's
# build.internal.lists hierarchy. VectorChord 1.1 added per-index fallback
# parameters for this reason: a session GUC overrides every vchordrq index,
@@ -63,11 +114,30 @@ _INDEX_TYPE_KEYWORDS = {
# indexes should attach probes to the index storage parameters instead.
# - pgvectorscale / pg_diskann / scann do not expose an equivalent per-statement
# knob in the engine today, so the dispatcher returns no statements for them.
#
# hnsw.iterative_scan is what makes ef_search a *batch* size rather than a ceiling.
# With it off (pgvector's default, and what Hindsight ran until now) the ground-layer
# search runs once and the scan ends when its list drains, so a query could never get
# more rows than ef_search however large its LIMIT — the recall budget moved the SQL
# and nothing else. With it on, the scan resumes in ef_search-sized rounds until the
# LIMIT is met, so each query gets the depth it asks for with no per-query setting.
# strict_order, not relaxed_order: the arms are trimmed in Python on the assumption
# that rows arrive ordered by distance.
#
# Retain-side link probing wants the opposite — it is tuned for latency, not depth,
# and resuming past its small candidate list would defeat that — so the low-latency
# profile pins it off. Both profiles set it explicitly rather than relying on the
# server default, so neither depends on what the other last left on the connection.
_ANN_TUNING_LOW_LATENCY: dict[str, tuple[tuple[str, str], ...]] = {
"pgvector": (("hnsw.ef_search", "60"),),
"pgvector": (("hnsw.ef_search", "60"), ("hnsw.iterative_scan", "off")),
}
_ANN_TUNING_HIGH_RECALL: dict[str, tuple[tuple[str, str], ...]] = {
"pgvector": (("hnsw.ef_search", "200"),),
"pgvector": (
("hnsw.ef_search", "200"),
("hnsw.iterative_scan", "strict_order"),
# Value filled in per call by ann_search_tuning_settings().
("hnsw.max_scan_tuples", ""),
),
}
_EXTENSION_INSTALL_SQL = {
@@ -167,7 +237,12 @@ def ann_search_tuning_settings(ext: str, *, kind: str) -> tuple[tuple[str, str],
table = _ANN_TUNING_HIGH_RECALL
else:
raise ValueError(f"Unknown ANN tuning kind: {kind!r}")
return table.get(_normalize_resolved(ext), ())
settings = table.get(_normalize_resolved(ext), ())
if not iterative_scan_enabled():
return tuple(pair for pair in settings if pair[0] not in _ITERATIVE_SCAN_GUCS)
return tuple(
(name, str(ann_max_scan_tuples()) if name == "hnsw.max_scan_tuples" else value) for name, value in settings
)
def uses_per_bank_vector_indexes(ext: str) -> bool:
@@ -175,6 +250,83 @@ def uses_per_bank_vector_indexes(ext: str) -> bool:
return _normalize_resolved(ext) != "scann"
def per_bank_index_min_rows() -> int:
"""Rows a (bank, fact_type) needs before it earns its own partial vector index.
Distinct from :func:`minimum_rows_for_index`, which is ScaNN's *build*
requirement for its single global index (AlloyDB cannot construct one below
a floor). This is a cost policy for the per-bank backends: the indexes sit on
the shared ``memory_units`` table, so each one is enumerated and locked at
plan time by queries belonging to every *other* bank, and opened by every DML
statement against the table. A small bank's index cannot repay that — the
``(bank_id, fact_type)`` B-tree plus a top-N sort answers the same query
exactly and faster. See issue #3485.
Read from config rather than passed in because the write path's pre-check,
the maintenance operation and the admin command must all apply the same
number; a threshold that differed between the one deciding to queue work and
the one deciding what to do would either oscillate or never converge.
"""
from .config import get_config
return get_config().vector_index_min_rows
def per_bank_index_drop_rows() -> int:
"""Row count below which an existing per-bank vector index is dropped.
Strictly below :func:`per_bank_index_min_rows` so the build and drop
decisions cannot both be true at one row count. Without the gap, a bank
hovering at the threshold — consolidation prunes a few facts, retain adds
them back — would rebuild and drop the same ANN index on alternating sweeps.
"""
from .config import VECTOR_INDEX_DROP_RATIO
return int(per_bank_index_min_rows() * VECTOR_INDEX_DROP_RATIO)
def should_keep_per_bank_index(row_count: int) -> bool:
"""Whether an *existing* index on a partition of ``row_count`` rows is kept.
The counterpart to :func:`qualifies_for_per_bank_index`, and deliberately a
separate, lower bound: keeping starts below building, so a partition
hovering at the threshold does not rebuild and drop the same ANN index on
alternating writes.
The ``row_count > 0`` term is not redundant with the ratio. At the default
threshold of 0 the drop floor is also 0, so a bare ``row_count >= floor``
keeps an index over an *emptied* partition forever — every bank ever written
to and then cleared would hold three indexes over nothing, which is the
accumulation the threshold exists to prevent. An emptied partition loses its
index at every threshold.
"""
return row_count > 0 and row_count >= per_bank_index_drop_rows()
def qualifies_for_per_bank_index(row_count: int) -> bool:
"""Whether a (bank, fact_type) holding ``row_count`` rows should have an index.
At the default threshold of 0 this is true for every partition that holds
any rows at all, which is the behaviour before the threshold existed.
An empty partition is excluded explicitly rather than by arithmetic: at a
threshold of 0, ``row_count >= minimum`` alone is true for zero rows, so
every bank in the deployment would be entitled to three indexes over nothing
the moment it was created — the exact index explosion the threshold exists
to prevent, reintroduced by its own default.
Only the build side: an existing index is kept until the count falls under
:func:`per_bank_index_drop_rows`, so callers reconciling live state must
consult both bounds rather than treating this as the full policy.
Takes no extension: the backend question is settled before any reconcile
runs (``uses_per_bank_vector_indexes`` gates the maintenance operation and
``_vector_index_clause`` gates the admin command), so re-asking it here
would be a second, weaker copy of a decision already made.
"""
return row_count > 0 and row_count >= per_bank_index_min_rows()
def bootstrap_extension(conn: Connection, ext: str) -> None:
"""Install the configured vector extension and any prerequisites if possible."""
normalized = validate_extension(ext)
+63 -21
View File
@@ -23,7 +23,12 @@ from ..engine.memory_engine import _current_schema
from ..engine.retain.bank_utils import _vector_index_clause
from ..engine.schema import fq_table_explicit as _fq_table
from ..engine.transfer import export_bank
from ..engine.vector_index_health import SchemaVectorIndexResult, repair_vector_indexes
from ..engine.vector_index_health import (
BankIndexResult,
drop_orphaned_bank_indexes,
list_bank_ids,
reconcile_bank_vector_indexes,
)
from ..extensions import TenantExtension, load_extension
from ..pg0 import parse_pg0_url, resolve_database_url
@@ -618,11 +623,14 @@ async def _run_repair_bank(
schema: str | None,
bank_id: str | None,
dry_run: bool,
) -> list[SchemaVectorIndexResult]:
) -> list[BankIndexResult]:
"""Reconcile per-(bank, fact_type) vector index coverage over a raw connection.
A single autocommit connection is used because ``CREATE INDEX CONCURRENTLY``
(used by ``repair_vector_indexes``) cannot run inside a transaction block.
cannot run inside a transaction block.
Deliberately unbudgeted, unlike the background operation: this is an operator
asking for convergence now, across as many banks as they named.
"""
schemas = [schema] if schema else await _resolve_schemas(base_schema)
index_clause = _vector_index_clause()
@@ -631,13 +639,38 @@ async def _run_repair_bank(
assert index_clause is not None
conn = await _admin_connect(db_url)
results: list[BankIndexResult] = []
try:
results = await repair_vector_indexes(conn, schemas, index_clause, dry_run=dry_run, bank_id=bank_id)
for result in results:
for target_schema in schemas:
try:
bank_ids = [bank_id] if bank_id else await list_bank_ids(conn, target_schema)
except Exception as exc: # noqa: BLE001 — one bad schema must not abort the sweep
typer.echo(f" schema '{target_schema}': skipped ({exc})", err=True)
continue
schema_results = [
await reconcile_bank_vector_indexes(conn, target_schema, bid, index_clause, dry_run=dry_run)
for bid in bank_ids
]
results.extend(schema_results)
# Only in --all mode: an index whose bank row is gone is unreachable
# from every bank-scoped path, so this is the one place that can
# collect it. Normally finds nothing — delete_bank drops a bank's
# indexes while it still knows their names — but a deployment that
# hit the #3485 wall could not run delete_bank at all.
orphans = [] if bank_id else await drop_orphaned_bank_indexes(conn, target_schema, dry_run=dry_run)
if orphans:
typer.echo(
f" schema '{target_schema}': {len(orphans)} orphaned index(es) "
f"{'to drop (dry-run)' if dry_run else 'dropped'} (no matching bank)"
)
typer.echo(
f" schema '{result.schema}': {result.banks_scanned} bank(s) scanned, "
f"{result.already_present} present, {result.created} created, "
f"{result.skipped} to-create (dry-run), {result.failed} failed"
f" schema '{target_schema}': {len(bank_ids)} bank(s) scanned, "
f"{sum(r.already_present for r in schema_results)} present, "
f"{sum(r.created for r in schema_results)} created, "
f"{sum(r.dropped for r in schema_results)} dropped, "
f"{sum(r.skipped for r in schema_results)} to-create (dry-run), "
f"{sum(r.would_drop for r in schema_results)} to-drop (dry-run), "
f"{sum(r.failed for r in schema_results)} failed"
)
return results
finally:
@@ -669,17 +702,23 @@ def repair_bank(
help="Report what would be repaired without creating or dropping any index.",
),
):
"""Verify and repair a bank's per-(bank, fact_type) vector index coverage.
"""Reconcile per-(bank, fact_type) vector index coverage against the size threshold.
Per-bank partial vector indexes are created when a bank is first created
(instant on an empty bank). Banks that arrive populated — via logical
restore, a cross-version upgrade, or a vector-extension switch — never hit
that path, so their recall silently falls back to a global index +
post-filter (slower, under-returning). This command detects missing OR
invalid coverage (an INVALID leftover or an index whose access method
drifted counts as missing) and rebuilds it with CREATE INDEX CONCURRENTLY,
so it never blocks the live fleet. Idempotent and safe to re-run — the
escape hatch after a restore, upgrade, or backend switch.
A (bank, fact_type) earns a partial vector index once it holds
HINDSIGHT_API_VECTOR_INDEX_MIN_ROWS rows; below that the planner answers the
same query exactly, and faster, from the (bank_id, fact_type) B-tree plus a
top-N sort. This command builds what qualifies and drops what no longer does
— including indexes orphaned by a deleted bank — detecting invalid coverage
too (an INVALID leftover, or an index whose access method drifted after a
backend switch, counts as missing). All DDL is CONCURRENTLY, so it never
blocks the live fleet.
Writes keep this converged on their own — every insert that could move a bank
across the threshold queues a vector_index_maintenance operation. Reach for
the command when you want convergence without waiting for a write: after a
restore or upgrade, after a backend switch, or to shed indexes in bulk on a
deployment recovering from lock-table exhaustion (#3485). Idempotent and safe
to re-run.
"""
if bool(bank_id) == all_banks:
typer.echo("Error: pass exactly one of --bank <id> or --all.", err=True)
@@ -713,15 +752,18 @@ def repair_bank(
)
)
total_banks = sum(r.banks_scanned for r in results)
total_banks = len(results)
total_present = sum(r.already_present for r in results)
total_created = sum(r.created for r in results)
total_dropped = sum(r.dropped for r in results)
total_skipped = sum(r.skipped for r in results)
total_would_drop = sum(r.would_drop for r in results)
total_failed = sum(r.failed for r in results)
typer.echo(
f"Done: {len(results)} schema(s), {total_banks} bank(s) scanned, "
f"{total_present} already present, {total_created} created, "
f"{total_skipped} to-create (dry-run), {total_failed} failed"
f"{total_present} already present, {total_created} created, {total_dropped} dropped, "
f"{total_skipped} to-create (dry-run), {total_would_drop} to-drop (dry-run), "
f"{total_failed} failed"
)
if total_failed:
failed_names = [name for r in results for name in r.failed_indexes]
+50 -10
View File
@@ -654,6 +654,17 @@ class MemoryItem(BaseModel):
default=None,
description="Optional entities to combine with auto-extracted entities.",
)
resolve_entities: bool = Field(
default=True,
description="Whether the names in 'entities' are resolved against the entities already in "
"the bank. True (default) matches each name to a similar existing entity when it scores "
"above the match threshold, so a name close to one already in the bank may resolve to that "
"one instead of the one you wrote. False takes your names literally — an existing entity is "
"reused only on a case-insensitive name match, any other name creates a new entity, and "
"your names are never merged with each other. This applies only to the entities you supply "
"here; auto-extracted entities are always resolved, since they are the extractor's guess at "
"a name rather than yours. Ignored when 'entities' is omitted.",
)
tags: list[str] | None = Field(
default=None,
description="Optional tags for visibility scoping. Memories with tags can be filtered during recall.",
@@ -1282,7 +1293,7 @@ class BankListItem(BaseModel):
class BankListResponse(BaseModel):
"""Response model for listing all banks."""
"""Response model for listing banks, one page at a time."""
model_config = ConfigDict(
json_schema_extra={
@@ -1299,12 +1310,18 @@ class BankListResponse(BaseModel):
"last_document_at": "2024-01-16T14:20:00Z",
"last_write_at": "2024-01-17T09:05:00Z",
}
]
],
"total": 50,
"limit": 100,
"offset": 0,
}
}
)
banks: list[BankListItem]
total: int = Field(description="Total number of banks visible to the caller, ignoring `limit`/`offset`.")
limit: int
offset: int
class CreateBankRequest(BaseModel):
@@ -1793,8 +1810,19 @@ class UpdateMemoryRequest(BaseModel):
)
entities: list[str] | None = Field(
default=None,
description="Replace the fact's entities. Names are resolved/find-or-created "
"the same way retain does; '[]' detaches all entities. Omit to leave unchanged.",
description="Replace the fact's entities. How each name is matched to an entity is "
"governed by 'resolve_entities'. '[]' detaches all entities. Omit to leave unchanged.",
)
resolve_entities: bool = Field(
default=True,
description="Whether the names in 'entities' are resolved against the entities already in "
"the bank. True (default) is what retain does: a similar existing entity is reused when it "
"scores above the match threshold, so a name close to one already in the bank may resolve "
"to that one instead of the one you wrote. False takes the names literally — an existing "
"entity is reused only on a case-insensitive name match, any other name creates a new "
"entity, and names in the same request are never merged with each other. Use False for "
"hand-authored corrections, where the name you sent is the answer rather than a guess. "
"Ignored when 'entities' is omitted.",
)
state: str | None = Field(
default=None,
@@ -4524,6 +4552,7 @@ def _register_routes(app: FastAPI):
occurred_end=occurred_end,
new_fact_type=request.fact_type,
entities=request.entities,
resolve_entities=request.resolve_entities,
state=request.state,
reason=request.reason,
request_context=request_context,
@@ -4937,16 +4966,26 @@ def _register_routes(app: FastAPI):
@app.get(
"/v1/default/banks",
response_model=BankListResponse,
summary="List all memory banks",
description="Get a list of all agents with their profiles",
summary="List memory banks",
description=(
"List banks with their profiles and summary stats, most recently written first "
"(`last_write_at` descending), with pagination and optional search."
),
operation_id="list_banks",
tags=["Banks"],
)
async def api_list_banks(request_context: RequestContext = Depends(get_request_context)):
"""Get list of all banks with their profiles."""
async def api_list_banks(
q: str | None = Query(None, description="Case-insensitive substring filter on bank ID or name (e.g. 'alice')"),
limit: int = Query(default=100, ge=0, description="Maximum number of banks to return"),
offset: int = Query(default=0, ge=0, description="Offset for pagination"),
request_context: RequestContext = Depends(get_request_context),
):
"""Get one page of banks with their profiles."""
try:
banks = await app.state.memory.list_banks(request_context=request_context)
return BankListResponse(banks=banks)
data = await app.state.memory.list_banks(
search_query=q, limit=limit, offset=offset, request_context=request_context
)
return BankListResponse(**data)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
@@ -8005,6 +8044,7 @@ def _register_routes(app: FastAPI):
content_dict["document_id"] = item.document_id
if item.entities:
content_dict["entities"] = [{"text": e.text, "type": e.type or "CONCEPT"} for e in item.entities]
content_dict["resolve_entities"] = item.resolve_entities
if item.tags:
content_dict["tags"] = item.tags
if item.observation_scopes is not None:
@@ -154,6 +154,13 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
"update_bank",
"delete_bank",
"clear_memories",
"get_knowledge_base_tree",
"search_knowledge_base",
"get_knowledge_page",
"create_knowledge_folder",
"create_knowledge_page",
"update_knowledge_node",
"delete_knowledge_node",
}
)
base_tools: frozenset[str] | None = None if multi_bank else _SINGLE_BANK_TOOLS
@@ -533,6 +533,8 @@ ENV_RERANKER_GOOGLE_PROJECT_ID = "HINDSIGHT_API_RERANKER_GOOGLE_PROJECT_ID"
ENV_RERANKER_GOOGLE_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_RERANKER_GOOGLE_SERVICE_ACCOUNT_KEY"
ENV_VECTOR_EXTENSION = "HINDSIGHT_API_VECTOR_EXTENSION"
ENV_ANN_ITERATIVE_SCAN = "HINDSIGHT_API_ANN_ITERATIVE_SCAN"
ENV_ANN_MAX_SCAN_TUPLES = "HINDSIGHT_API_ANN_MAX_SCAN_TUPLES"
ENV_TEXT_SEARCH_EXTENSION = "HINDSIGHT_API_TEXT_SEARCH_EXTENSION"
ENV_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE = "HINDSIGHT_API_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE"
ENV_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER = "HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER"
@@ -755,6 +757,7 @@ WORKER_SLOT_TYPE_DEFAULTS: dict[str, int] = {
"file_convert_retain": 0,
"refresh_mental_model": 0,
"graph_maintenance": 0,
"vector_index_maintenance": 0,
"import_documents": 0,
"export_documents": 0,
}
@@ -876,6 +879,7 @@ ENV_MENTAL_MODEL_REFRESH_TICK_SECONDS = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_TICK
ENV_RETENTION_SWEEP_INTERVAL_SECONDS = "HINDSIGHT_API_RETENTION_SWEEP_INTERVAL_SECONDS"
ENV_OPERATION_CLEANUP_INTERVAL_SECONDS = "HINDSIGHT_API_OPERATION_CLEANUP_INTERVAL_SECONDS"
ENV_MAINTENANCE_START_JITTER_SECONDS = "HINDSIGHT_API_MAINTENANCE_START_JITTER_SECONDS"
ENV_VECTOR_INDEX_MIN_ROWS = "HINDSIGHT_API_VECTOR_INDEX_MIN_ROWS"
# Disposition settings
ENV_DISPOSITION_SKEPTICISM = "HINDSIGHT_API_DISPOSITION_SKEPTICISM"
@@ -1142,6 +1146,22 @@ DEFAULT_RERANKER_GOOGLE_MODEL = "semantic-ranker-default-004"
# Vector extension (pgvector, vchord, pgvectorscale, or AlloyDB ScaNN)
DEFAULT_VECTOR_EXTENSION = "pgvector" # Options: "pgvector", "vchord", "pgvectorscale", "scann"
# Let an ANN scan resume until the query's LIMIT is met, instead of stopping when its
# first candidate list drains. Off, a recall can never retrieve more rows than the
# candidate list holds (pgvector: hnsw.ef_search, 200), so a larger recall budget
# widens the SQL and changes nothing. On is the intended behaviour; this exists as an
# operational kill switch, because turning it off restores exactly the previous
# retrieval depth without a deploy.
DEFAULT_ANN_ITERATIVE_SCAN = True
# Ceiling on how many tuples one resumed scan may visit. Bounds both the CPU a
# selective query can spend resuming (the filters that thin a result are applied after
# the index scan, so a selective one resumes repeatedly) and the scan's memory, which
# pgvector otherwise caps at work_mem * hnsw.scan_mem_multiplier. Measured at this
# value the memory ceiling is never approached — squeezing work_mem to 256kB changes
# neither rows nor latency — so this is the knob that governs the cost, not work_mem.
# Lower it to trade retrieval depth back for latency; the initial scan is not counted,
# so even 1 leaves the pre-existing behaviour intact. pgvector's own default is 20000.
DEFAULT_ANN_MAX_SCAN_TUPLES = 4000
# Text search extension (native PostgreSQL, vchord BM25, Timescale pg_textsearch,
# pgroonga, or ParadeDB pg_search)
@@ -1484,6 +1504,31 @@ DEFAULT_OPERATION_CLEANUP_INTERVAL_SECONDS = 900
# the same instant. 0 disables the jitter (deterministic start).
DEFAULT_MAINTENANCE_START_JITTER_SECONDS = 60
# Rows a (bank, fact_type) partition needs before it gets its own partial vector
# index. These indexes live on the *shared* memory_units table: PostgreSQL locks
# and builds an IndexOptInfo for every index on a relation at plan time, and
# opens every one of them for each DML statement, so one bank's index is a cost
# paid by every other bank in the deployment. Three per bank exhausts the lock
# table at a few thousand banks (issue #3485).
#
# 0 is the default and means "no minimum": every partition that holds rows gets
# an index, which is the behaviour before the threshold existed. Deployments
# holding thousands of banks raise it — above the threshold ANN wins, and below
# it PostgreSQL answers the same query from the (bank_id, fact_type) B-tree plus
# a top-N sort, which is exact rather than approximate *and* faster, because
# sorting a few thousand rows by distance costs less than descending an ANN
# graph. 10_000 is a reasonable starting point (it is also ScaNN's own build
# floor, SCANN_MIN_ROWS_FOR_AUTO_INDEX).
DEFAULT_VECTOR_INDEX_MIN_ROWS = 0
# A partition that falls back below MIN_ROWS * this ratio loses its index. The
# gap between the build and drop thresholds is hysteresis: with a single
# boundary, consolidation pruning a bank back and forth across it would rebuild
# and drop the same ANN index on alternating writes. At the default threshold of
# 0 there is no gap and nothing to flap — a partition either holds rows or does
# not.
VECTOR_INDEX_DROP_RATIO = 0.5
# Default MCP tool descriptions (can be customized via env vars)
DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory.
@@ -2163,6 +2208,8 @@ class HindsightConfig:
migration_database_url: str | None
database_schema: str
vector_extension: str # "pgvector", "vchord", "pgvectorscale", or "scann"
ann_iterative_scan: bool
ann_max_scan_tuples: int
text_search_extension: str # "native", "vchord", "pg_textsearch", "pgroonga", or "pg_search"
# PostgreSQL text search dictionary for the "native" backend (ignored by
# other backends). Only the "native" backend reads this field; pgroonga
@@ -2655,6 +2702,9 @@ class HindsightConfig:
# How often the maintenance loop checks for cron-scheduled mental models due for
# refresh (the per-model schedule lives in the mental model trigger). 0 = disabled.
mental_model_refresh_tick_seconds: int
# Rows a (bank, fact_type) needs before it gets its own partial vector index.
# 0 (default) = no minimum: every partition holding rows is indexed.
vector_index_min_rows: int
# Webhook configuration (static - server-level only, not per-bank)
webhook_url: str | None # Global webhook URL (None = disabled)
@@ -2977,6 +3027,12 @@ class HindsightConfig:
# Validate vector_extension
validate_extension(self.vector_extension)
if self.ann_iterative_scan and self.ann_max_scan_tuples < 1:
raise ValueError(
f"Invalid ann_max_scan_tuples: {self.ann_max_scan_tuples}. Must be >= 1 when "
f"iterative ANN scans are enabled (set {ENV_ANN_ITERATIVE_SCAN}=false to disable them)"
)
# pg_trgm requires the similarity threshold in (0, 1]. Fail fast here
# rather than let an out-of-range value raise on every pool connection's
# setup (which would leave the API unable to serve any request).
@@ -3145,6 +3201,10 @@ class HindsightConfig:
migration_database_url=os.getenv(ENV_MIGRATION_DATABASE_URL) or None,
database_schema=os.getenv(ENV_DATABASE_SCHEMA, DEFAULT_DATABASE_SCHEMA),
vector_extension=os.getenv(ENV_VECTOR_EXTENSION, DEFAULT_VECTOR_EXTENSION).lower(),
ann_iterative_scan=_parse_boolean_env(ENV_ANN_ITERATIVE_SCAN, DEFAULT_ANN_ITERATIVE_SCAN),
ann_max_scan_tuples=_parse_non_negative_int(
ENV_ANN_MAX_SCAN_TUPLES, os.getenv(ENV_ANN_MAX_SCAN_TUPLES), DEFAULT_ANN_MAX_SCAN_TUPLES
),
text_search_extension=os.getenv(ENV_TEXT_SEARCH_EXTENSION, DEFAULT_TEXT_SEARCH_EXTENSION).lower(),
text_search_extension_native_language=os.getenv(
ENV_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE,
@@ -4017,6 +4077,11 @@ class HindsightConfig:
os.getenv(ENV_RETENTION_SWEEP_INTERVAL_SECONDS),
DEFAULT_RETENTION_SWEEP_INTERVAL_SECONDS,
),
vector_index_min_rows=_parse_non_negative_int(
ENV_VECTOR_INDEX_MIN_ROWS,
os.getenv(ENV_VECTOR_INDEX_MIN_ROWS),
DEFAULT_VECTOR_INDEX_MIN_ROWS,
),
operation_cleanup_interval_seconds=_parse_non_negative_int(
ENV_OPERATION_CLEANUP_INTERVAL_SECONDS,
os.getenv(ENV_OPERATION_CLEANUP_INTERVAL_SECONDS),
@@ -55,6 +55,7 @@ if TYPE_CHECKING:
from asyncpg import Connection
from ...api.http import RequestContext
from ..memories.base import StoredMemory
from ..memory_engine import MemoryEngine
from ..response_models import MemoryFact, RecallResult
@@ -221,6 +222,52 @@ def _dedup_active(config: Any) -> bool:
return get_config().database_backend != "oracle"
@dataclass(frozen=True)
class _TemporalBounds:
"""The temporal columns an observation inherits from the facts behind it.
Merging two observations (or an observation and a fresh set of source facts) must widen
these, never replace them: ``event_date``/``occurred_start`` keep the earliest known value
and ``occurred_end``/``mentioned_at`` the latest, with a missing value on either side
ignored. That is exactly the ``_aggregate_source_fields`` rule, and the Python mirror of the
``LEAST``/``GREATEST`` the SQL paths apply.
The SQL spelling differs by reach, deliberately. The dedup folds only ever run on PostgreSQL
(``_dedup_active`` disables dedup on Oracle) and use the plain
``LEAST(col, COALESCE(x, col))``, which is enough there because PostgreSQL ignores NULL
arguments. ``_execute_update_action`` also runs on Oracle, where LEAST/GREATEST return NULL
if any argument is NULL, so it wraps the whole expression in one more COALESCE — see the
comment there.
"""
event_date: "datetime | None" = None
occurred_start: "datetime | None" = None
occurred_end: "datetime | None" = None
mentioned_at: "datetime | None" = None
@classmethod
def of(cls, row: "StoredMemory | _SourceAggregation") -> "_TemporalBounds":
"""The bounds carried by a stored memory or by an aggregation over source facts.
Deliberately not a recall ``MemoryFact``: that model has no ``event_date`` at all and
keeps the rest as ISO strings, so it has to be read field by field where it is used.
"""
return cls(
event_date=row.event_date,
occurred_start=row.occurred_start,
occurred_end=row.occurred_end,
mentioned_at=row.mentioned_at,
)
def merged_with(self, other: "_TemporalBounds") -> "_TemporalBounds":
return _TemporalBounds(
event_date=_merge_min(self.event_date, other.event_date),
occurred_start=_merge_min(self.occurred_start, other.occurred_start),
occurred_end=_merge_max(self.occurred_end, other.occurred_end),
mentioned_at=_merge_max(self.mentioned_at, other.mentioned_at),
)
@dataclass
class _DedupOutcome:
"""Result of probing one observation against its in-scope neighbours.
@@ -322,6 +369,7 @@ async def _dedup_reconcile_create(
create_text: str,
create_source_ids: list[uuid.UUID],
tags: list[str] | None,
source_bounds: _TemporalBounds,
txn=None,
) -> str | None:
"""Semantic dedup for a single CREATE (create-time, focused 1-by-1).
@@ -330,6 +378,10 @@ async def _dedup_reconcile_create(
observation and returns its id (caller skips the CREATE). Returns None when there is
no near twin or the LLM keeps them distinct.
``source_bounds`` are the dates the skipped CREATE would have been stamped with. They are
folded into the twin too: this path bypasses the CREATE writer, so without them the twin
would cite dated source facts while reporting the dates of its original sources only (#3477).
The probe/embed/LLM adjudication runs with no connection held; the fold takes a
short-lived connection and re-checks source liveness inside the fold transaction.
"""
@@ -362,6 +414,10 @@ async def _dedup_reconcile_create(
SET text = $1,
source_memory_ids = (SELECT array_agg(DISTINCT e) FROM unnest(source_memory_ids || $2::uuid[]) e),
proof_count = (SELECT count(DISTINCT e) FROM unnest(source_memory_ids || $2::uuid[]) e),
event_date = LEAST(event_date, COALESCE($5, event_date)),
occurred_start = LEAST(occurred_start, COALESCE($6, occurred_start)),
occurred_end = GREATEST(occurred_end, COALESCE($7, occurred_end)),
mentioned_at = GREATEST(mentioned_at, COALESCE($8, mentioned_at)),
updated_at = now(){search_vector_clause}
WHERE id = $3::uuid AND text = $4
RETURNING id
@@ -370,6 +426,10 @@ async def _dedup_reconcile_create(
live_source_ids,
uuid.UUID(outcome.best_id),
outcome.best_text,
source_bounds.event_date,
source_bounds.occurred_start,
source_bounds.occurred_end,
source_bounds.mentioned_at,
)
if folded is None:
# The twin vanished (or was rewritten) during the connection-free LLM window.
@@ -382,7 +442,15 @@ async def _dedup_reconcile_create(
return None
else:
await _reconcile_merge_via_store(
store, conn, memory_engine, bank_id, outcome.best_id, outcome.merged_text, live_source_ids, txn=txn
store,
conn,
memory_engine,
bank_id,
outcome.best_id,
outcome.merged_text,
live_source_ids,
source_bounds,
txn=txn,
)
return outcome.best_id
@@ -426,7 +494,8 @@ async def _dedup_reconcile_update(
# Fold the updated observation's live sources into the twin (keeping the twin's embedding, as
# in the create path) then delete the now-redundant updated row. The all_strict/any tag match
# guarantees twin and updated share scope, so dropping the updated row's tags loses no
# visibility. Temporal fields follow the surviving twin (minimal scope; matches create).
# visibility. Temporal fields are the UNION of both rows' bounds: the updated row is about to
# be deleted, so anything only it knew about would otherwise be lost with it (#3477).
# The fold + delete share one short transaction so the twin gains the sources exactly as the
# redundant row is removed; the slow adjudication above already ran connection-free.
store = get_memories()
@@ -469,6 +538,10 @@ async def _dedup_reconcile_update(
proof_count = (
SELECT count(DISTINCT e) FROM unnest(t.source_memory_ids || $6::uuid[]) e
),
event_date = LEAST(t.event_date, COALESCE(u.event_date, t.event_date)),
occurred_start = LEAST(t.occurred_start, COALESCE(u.occurred_start, t.occurred_start)),
occurred_end = GREATEST(t.occurred_end, COALESCE(u.occurred_end, t.occurred_end)),
mentioned_at = GREATEST(t.mentioned_at, COALESCE(u.mentioned_at, t.mentioned_at)),
updated_at = now(){search_vector_clause}
FROM {fq_table("memory_units")} u
WHERE t.id = $2::uuid AND u.id = $3::uuid AND t.text = $4 AND u.text = $5
@@ -494,7 +567,15 @@ async def _dedup_reconcile_update(
if not live_u_sources:
return
await _reconcile_merge_via_store(
store, conn, memory_engine, bank_id, outcome.best_id, outcome.merged_text, live_u_sources, txn=txn
store,
conn,
memory_engine,
bank_id,
outcome.best_id,
outcome.merged_text,
live_u_sources,
_TemporalBounds.of(updated_obs[0]),
txn=txn,
)
await _execute_delete_action(conn, bank_id, updated_id, txn=txn)
logger.info(
@@ -1001,17 +1082,22 @@ async def _reconcile_merge_via_store(
observation_id: str,
merged_text: str,
add_source_ids: list,
add_bounds: _TemporalBounds,
txn=None,
) -> None:
"""Dedup merge for a store that owns its rows: fold the extra source facts and the merged text
into the twin observation and re-upsert it, preserving its other fields. Re-embeds the merged
text because ``get_memories`` does not return the stored vector (the SQL path reuses it in
place instead)."""
place instead).
``add_bounds`` are the folded-in side's dates, widened onto the twin exactly as the SQL
path's LEAST/GREATEST does."""
current = await store.get_memories(conn=conn, fq_table=fq_table, bank_id=bank_id, unit_ids=[observation_id])
cur = current[0] if current else None
if cur is None:
return
merged_sources = list(dict.fromkeys([*(cur.source_memory_ids or []), *(str(s) for s in add_source_ids)]))
merged_bounds = _TemporalBounds.of(cur).merged_with(add_bounds)
embeddings = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [merged_text])
await store.upsert_observation(
conn=conn,
@@ -1025,10 +1111,10 @@ async def _reconcile_merge_via_store(
tags=list(cur.tags or []),
proof_count=len(merged_sources),
source_memory_ids=merged_sources,
event_date=cur.event_date,
occurred_start=cur.occurred_start,
occurred_end=cur.occurred_end,
mentioned_at=cur.mentioned_at,
event_date=merged_bounds.event_date,
occurred_start=merged_bounds.occurred_start,
occurred_end=merged_bounds.occurred_end,
mentioned_at=merged_bounds.mentioned_at,
created_at=cur.created_at,
),
)
@@ -2134,9 +2220,7 @@ async def _process_memory_batch(
new_text=update.text,
observations=union_observations,
source_fact_tags=agg.tags,
source_occurred_start=agg.occurred_start,
source_occurred_end=agg.occurred_end,
source_mentioned_at=agg.mentioned_at,
source_bounds=_TemporalBounds.of(agg),
perf=perf,
txn=txn,
)
@@ -2204,6 +2288,7 @@ async def _process_memory_batch(
create.text,
create_source_ids,
agg.tags,
_TemporalBounds.of(agg),
txn=txn,
)
if merged_into is not None:
@@ -2338,17 +2423,15 @@ async def _execute_update_action(
new_text: str,
observations: list["MemoryFact"],
source_fact_tags: list[str] | None = None,
source_occurred_start: datetime | None = None,
source_occurred_end: datetime | None = None,
source_mentioned_at: datetime | None = None,
source_bounds: _TemporalBounds = _TemporalBounds(),
perf: ConsolidationPerfLog | None = None,
txn=None,
) -> str | None:
"""
Update an existing observation.
Extends source_memory_ids with all contributing memories, updates temporal fields
(LEAST for occurred_start, GREATEST for occurred_end / mentioned_at), and merges tags.
Extends source_memory_ids with all contributing memories, widens the observation's temporal
bounds by ``source_bounds`` (see :class:`_TemporalBounds`), and merges tags.
The embedding is computed off-connection (a slow embedder must never pin a pooled
connection); the liveness check + UPDATE + history + observation_sources sync then run
@@ -2418,6 +2501,15 @@ async def _execute_update_action(
t0 = time.time()
if store.writes_memory_rows_in_sql_for(bank_id):
# Unlike the dedup folds this statement also runs on Oracle, where LEAST/GREATEST
# return NULL as soon as ANY argument is NULL (PostgreSQL ignores NULL arguments).
# The inner COALESCE covers a NULL *parameter*; the outer one covers a NULL
# *column* — an observation with no occurred interval yet, which is precisely the
# #3477 case. Without it Oracle would compute LEAST(NULL, <source date>) = NULL and
# silently drop the date it was told to inherit. Keep the inner
# ``COALESCE($n, col)`` spelled exactly like this: the Oracle driver shim keys its
# TIMESTAMP-TZ input-size hint off that pattern (db/oracle.py::_apply_clob_input_sizes),
# and a NULL parameter binds as VARCHAR2 (ORA-00932) without it.
updated_rows = await conn.execute_rows_affected(
f"""
UPDATE {fq_table("memory_units")}
@@ -2425,11 +2517,12 @@ async def _execute_update_action(
embedding = $2::vector,
source_memory_ids = $3,
proof_count = $4,
tags = $9,
tags = $10,
updated_at = now(),
occurred_start = LEAST(occurred_start, COALESCE($6, occurred_start)),
occurred_end = GREATEST(occurred_end, COALESCE($7, occurred_end)),
mentioned_at = GREATEST(mentioned_at, COALESCE($8, mentioned_at)){search_vector_clause}
event_date = COALESCE(LEAST(event_date, COALESCE($6, event_date)), $6),
occurred_start = COALESCE(LEAST(occurred_start, COALESCE($7, occurred_start)), $7),
occurred_end = COALESCE(GREATEST(occurred_end, COALESCE($8, occurred_end)), $8),
mentioned_at = COALESCE(GREATEST(mentioned_at, COALESCE($9, mentioned_at)), $9){search_vector_clause}
WHERE id = $5
""",
new_text,
@@ -2437,9 +2530,10 @@ async def _execute_update_action(
source_ids,
len(source_ids),
uuid.UUID(observation_id),
source_occurred_start,
source_occurred_end,
source_mentioned_at,
source_bounds.event_date,
source_bounds.occurred_start,
source_bounds.occurred_end,
source_bounds.mentioned_at,
merged_tags,
)
# The source-liveness checks above guard the *source* memories; the
@@ -2457,12 +2551,24 @@ async def _execute_update_action(
return None
else:
# Upsert overwrites the whole observation, so start from its current state (fetched
# from the store) and apply the same merge the SQL does — LEAST/GREATEST on the times
# — while preserving fields the update never touches (event_date, created_at).
# from the store) and apply the same merge the SQL does — LEAST/GREATEST on the
# times — while preserving fields the update never touches (created_at).
current = await store.get_memories(
conn=conn, fq_table=fq_table, bank_id=bank_id, unit_ids=[observation_id]
)
cur = current[0] if current else None
# Widen the row the store still holds. If it has vanished, fall back to the
# pre-update recall snapshot — ISO strings, and no event_date on that model.
current_bounds = (
_TemporalBounds.of(cur)
if cur
else _TemporalBounds(
occurred_start=_as_dt(model.occurred_start),
occurred_end=_as_dt(model.occurred_end),
mentioned_at=_as_dt(model.mentioned_at),
)
)
merged_bounds = current_bounds.merged_with(source_bounds)
await store.upsert_observation(
conn=conn,
bank_id=bank_id,
@@ -2475,10 +2581,10 @@ async def _execute_update_action(
tags=merged_tags,
proof_count=len(source_ids),
source_memory_ids=[str(s) for s in source_ids],
event_date=cur.event_date if cur else None,
occurred_start=_merge_min(model.occurred_start, source_occurred_start),
occurred_end=_merge_max(model.occurred_end, source_occurred_end),
mentioned_at=_merge_max(model.mentioned_at, source_mentioned_at),
event_date=merged_bounds.event_date,
occurred_start=merged_bounds.occurred_start,
occurred_end=merged_bounds.occurred_end,
mentioned_at=merged_bounds.mentioned_at,
created_at=cur.created_at if cur else None,
),
)
@@ -481,23 +481,11 @@ class DataAccessOps(ABC):
# -- Bank index management -------------------------------------------
@abstractmethod
async def create_bank_vector_indexes(
self,
conn: DatabaseConnection,
table: str,
bank_id: str,
internal_id: str,
index_clause: str,
fact_types: dict[str, str],
) -> None:
"""Create per-bank partial vector indexes.
PG creates per-(bank, fact_type) partial indexes.
Non-PG is a no-op (uses global index).
"""
...
# No create counterpart: per-(bank, fact_type) partial vector indexes are
# earned by size and built by the maintenance sweep over its own autocommit
# connection (see engine/vector_index_health.py), never on a request path.
# The drop stays here because bank deletion must remove a large bank's
# indexes while it still knows the internal_id they are named after.
@abstractmethod
async def drop_bank_vector_indexes(
self,
@@ -696,6 +696,21 @@ class OracleOps(DataAccessOps):
# Entity expansion via observation_sources junction table.
# Previously used JSON_TABLE to explode source_memory_ids CLOB. The junction
# table approach uses standard SQL joins, identical to the PG backend.
#
# Two PostgreSQL fixes are deliberately NOT mirrored here, because neither
# was measured against Oracle and both are tuned to PostgreSQL's planner:
# - #3085 made PG score set-wise; the scoring below is still the
# correlated per-observation COUNT(*). On Oracle that counts rows of
# the indexed observation_sources junction table rather than scanning
# an unpruned array, so it is a much weaker version of that problem.
# - #3510 replaced PG's `DISTINCT` over a `LATERAL ... LIMIT` with a
# row_number() window, because PostgreSQL cannot estimate the row count
# of that shape and mis-planned the scoring join into a nested loop.
# `connected_sources` below has the same shape, so the same collapse is
# structurally possible, but Oracle's cardinality estimation differs and
# no Oracle instance was available to measure it.
# If observation recall is reported slow on Oracle, start by capturing the
# plan for connected_sources and checking its estimated vs actual rows.
from ..schema import fq_table
obs_sources_table = fq_table("observation_sources")
@@ -829,23 +844,6 @@ class OracleOps(DataAccessOps):
bank_prefix="mu.",
)
async def create_bank_vector_indexes(
self,
conn: DatabaseConnection,
table: str,
bank_id: str,
internal_id: str,
index_clause: str,
fact_types: dict[str, str],
) -> None:
# Oracle 23ai supports HNSW vector indexes but does NOT support partial
# indexes (WHERE clause on CREATE INDEX for vector indexes). Uses a single
# global HNSW index with ORGANIZATION NEIGHBOR PARTITIONS created during
# migrations. memory_units is partitioned by LIST (bank_id) AUTOMATIC,
# so Oracle creates partitions per bank on INSERT and the optimizer can
# prune partitions on bank_id-scoped queries.
return
async def drop_bank_vector_indexes(
self,
conn: DatabaseConnection,
@@ -853,7 +851,12 @@ class OracleOps(DataAccessOps):
internal_id: str,
fact_types: dict[str, str],
) -> None:
# Oracle uses a single global vector index (no per-bank indexes to drop).
# Oracle uses a single global vector index — it does not support partial
# (WHERE-clause) vector indexes, so there are no per-bank ones to drop.
# Bank scoping comes from the table itself instead: memory_units is
# partitioned LIST (bank_id) AUTOMATIC, so Oracle creates a partition per
# bank on INSERT and the optimizer prunes on bank_id. That is why the
# size threshold and its sweep are PostgreSQL-only concerns.
return
def get_entity_resolution_strategy(self) -> str:
@@ -914,6 +914,27 @@ class PostgreSQLOps(DataAccessOps):
# ~1.7B element comparisons, 2.6s of one saturated backend (issue #3085).
# Unnesting once and hash-joining connected_sources makes the work linear in
# the number of source ids instead.
#
# `connected_sources` caps each entity with row_number() rather than the
# LATERAL + LIMIT that reads more naturally. Do not "simplify" it back
# (issue #3510). The scoring join above is O(C + U) when planned as a hash
# join and O(U x C) when planned as a nested loop — 15s and ~15M rejected
# rows on a realistically-shaped bank — and PostgreSQL picks between them
# from its row estimate for this CTE. Out of a LATERAL + LIMIT subquery the
# capped column carries no n_distinct statistic, so DISTINCT over it was
# estimated at 2 and the NOT EXISTS took that to 1 against an actual ~3,700;
# a 1-row inner side makes the nested loop look free, so it won on cost and
# lost by four orders of magnitude at runtime. Ranking with a window keeps
# the column traceable to unit_entities.unit_id, so the estimate comes from
# real statistics (207-3,449 against 2,242-4,193 actual) and the nested loop
# is priced honestly.
#
# The trade is that this reads every unit_entities row of a matched entity
# to rank it, where the LATERAL stopped at per_entity_limit off the index:
# O(sum of degree) rather than O(entities x per_entity_limit). Measured at
# parity up to ~12k-degree hubs and +50% traversal cost at 38k. If banks
# grow hubs far past that, re-measure before assuming this is still the
# right shape.
entity_rows = await conn.fetch(
f"""
@@ -930,17 +951,20 @@ class PostgreSQLOps(DataAccessOps):
),
connected_sources AS (
SELECT DISTINCT t.unit_id AS source_id
FROM source_entities se
CROSS JOIN LATERAL (
SELECT ue_target.unit_id
FROM (
SELECT
ue_target.unit_id,
row_number() OVER (
PARTITION BY ue_target.entity_id
ORDER BY ue_target.unit_id DESC
) AS rn
FROM {ue_table} ue_target
WHERE ue_target.entity_id = se.entity_id
ORDER BY ue_target.unit_id DESC
LIMIT {per_entity_limit}
JOIN source_entities se ON se.entity_id = ue_target.entity_id
) t
WHERE NOT EXISTS (
SELECT 1 FROM seed_sources ss WHERE ss.source_id = t.unit_id
)
WHERE t.rn <= {per_entity_limit}
AND NOT EXISTS (
SELECT 1 FROM seed_sources ss WHERE ss.source_id = t.unit_id
)
),
connected_array AS (
SELECT array_agg(source_id) AS source_ids FROM connected_sources
@@ -1047,26 +1071,6 @@ class PostgreSQLOps(DataAccessOps):
bank_prefix="",
)
async def create_bank_vector_indexes(
self,
conn: DatabaseConnection,
table: str,
bank_id: str,
internal_id: str,
index_clause: str,
fact_types: dict[str, str],
) -> None:
escaped = bank_id.replace("'", "''")
async with self._index_ddl_lock(table):
for ft, suffix in fact_types.items():
uid = str(internal_id).replace("-", "")[:16]
idx = f"idx_mu_emb_{suffix}_{uid}"
await conn.execute(
f"CREATE INDEX IF NOT EXISTS {idx} "
f"ON {table} {index_clause} "
f"WHERE fact_type = '{ft}' AND bank_id = '{escaped}'"
)
async def drop_bank_vector_indexes(
self,
conn: DatabaseConnection,
@@ -1080,8 +1084,14 @@ class PostgreSQLOps(DataAccessOps):
# table; CONCURRENTLY does not conflict with DML. The caller
# (delete_bank) runs this on an autocommit connection after its delete
# transaction has committed — CONCURRENTLY cannot run inside a tx.
# The lock key must match create_bank_vector_indexes', whose `table`
# is the fq name this reconstructs from `schema`.
#
# The in-process lock serializes concurrent bank deletes against each
# other. It does not cover the maintenance sweep, which reconciles the
# same indexes over its own raw connection: an in-process lock could not
# help there anyway, since the sweep runs in every process and the real
# contention is cross-process. Both paths retry the transient deadlock
# (40P01) instead, which is the only lock-free option available — the
# project forbids advisory locks (unreliable behind poolers, #2817).
async with self._index_ddl_lock(f"{schema}.memory_units"):
for ft, suffix in fact_types.items():
uid = str(internal_id).replace("-", "")[:16]
@@ -20,6 +20,24 @@ from .pool_instrumentation import PoolStats, instrument_acquire
logger = logging.getLogger(__name__)
# GUC names this server rejected as unknown. Process-wide and never cleared: the
# server's extension set does not change under a running process, and re-probing
# would reintroduce the per-acquire cost this exists to avoid.
_unsupported_settings: set[str] = set()
def setting_rejected_by_server(name: str) -> bool:
"""Whether this server has already rejected ``name`` as an unknown GUC.
For callers that apply a setting outside this helper — notably retain's link
probing, which uses SET LOCAL inside its own transaction so the value cannot leak
onto a pooled backend. Such a caller cannot simply let the statement fail: an error
inside a transaction poisons it, so an unknown GUC would abort its work rather than
merely fail to apply. The pool's setup runs on acquire and names the same GUCs, so
by the time one of those callers runs, an unknown one is already recorded here.
"""
return name in _unsupported_settings
async def apply_session_settings(conn: asyncpg.Connection, settings: list[tuple[str, str]]) -> None:
"""Apply session-scoped GUCs to ``conn`` in a single round trip.
@@ -37,6 +55,7 @@ async def apply_session_settings(conn: asyncpg.Connection, settings: list[tuple[
statement fails as a whole, so on error fall back to applying them one by
one, skipping only the ones the server rejects.
"""
settings = [pair for pair in settings if pair[0] not in _unsupported_settings]
if not settings:
return
@@ -53,8 +72,19 @@ async def apply_session_settings(conn: asyncpg.Connection, settings: list[tuple[
for name, value in settings:
try:
await conn.execute("SELECT set_config($1, $2, false)", name, value)
except asyncpg.exceptions.UndefinedObjectError:
# The server does not define this GUC — an extension we tune for is absent
# or predates it (hnsw.iterative_scan needs pgvector 0.8+, and pgvector
# reserves the "hnsw." prefix, so an older one rejects it rather than
# accepting a placeholder). Remember it: otherwise every acquire from here
# on re-pays a failed batch plus one statement per setting, which behind a
# transaction-mode pooler is a server-side transaction each — the burn
# #3499 removed. Narrow to UndefinedObjectError so a transient failure
# does not disable a setting the server does support.
logger.info("Server does not know %s — not sending it again on this process", name)
_unsupported_settings.add(name)
except asyncpg.exceptions.PostgresError:
logger.debug("Could not set %sthe server may not know this setting", name)
logger.debug("Could not set %sretrying it on the next acquire", name)
class PostgresConnection(DatabaseConnection):
@@ -164,6 +194,12 @@ class PostgreSQLBackend(DatabaseBackend):
self._pool: asyncpg.Pool | None = None
self._acquire_warn_threshold_s: float = 1.0
self._acquire_timeout_s: float | None = None
self._dsn: str | None = None
@property
def dsn(self) -> str | None:
"""The DSN this backend's pool was opened with, if it has been initialized."""
return self._dsn
async def initialize(
self,
@@ -179,6 +215,13 @@ class PostgreSQLBackend(DatabaseBackend):
from ...config import get_config
config = get_config()
# Kept so code that needs its *own* connection — CREATE/DROP INDEX
# CONCURRENTLY cannot run on a pooled one inside a transaction — can
# reach the database this engine is actually attached to. Re-deriving it
# from HINDSIGHT_API_DATABASE_URL is wrong whenever the engine was handed
# a DSN directly (embedders, and the test suite, which resolves pg0 in a
# fixture and never sets the env var).
self._dsn = dsn
self._acquire_warn_threshold_s = config.db_acquire_warn_threshold_ms / 1000.0
# Kept for acquire() below: asyncpg's ``timeout`` create_pool kwarg is a
# *connect* kwarg (how long establishing a new connection may take), and
@@ -45,6 +45,9 @@ class _EntityToCreate:
# Also stored on the row as entities.entity_kind so label rows stay out of the
# partial trigram index (#3208).
is_label: bool = False
# False when the caller wrote this name literally: it is created as spelled and never
# merged with a same-batch near-duplicate (#3479).
resolve: bool = True
@dataclass
@@ -428,6 +431,18 @@ class EntityResolver:
unit_event_date: When this unit was created
conn: Optional connection to use (if None, acquires from pool)
Each mention may carry ``"resolve": False`` to opt out of resolution. The
default, True, treats a name as a *guess* at which entity is meant, so
similar existing entities are scored on name similarity + co-occurrence +
recency and the best above threshold is reused. False takes the name
literally: an existing entity is reused only when its canonical name
matches case-insensitively, any other name creates its own entity, and it
is never merged with a same-batch near-duplicate. Callers who authored the
names deliberately want False (#3479) — resolution would otherwise let
what the graph already believes outscore, and silently discard, their
correction. It is per mention because retain resolves caller-supplied and
extracted names in one batch, and only the caller's half is authoritative.
Returns:
Resolved entity identities (id + stored canonical name) in the same
order as input.
@@ -457,6 +472,25 @@ class EntityResolver:
taxonomy_lookup: set[str] | None = None,
labels_cfg=None,
) -> list[ResolvedEntity]:
# `entities_data and` matters: an empty batch must fall through to the normal strategy
# dispatch (which the pg_trgm auto-detection hangs off), not take the shortcut vacuously.
if entities_data and not any(e.get("resolve", True) for e in entities_data):
# Nothing in this batch resolves, so the trigram/UTL_MATCH probe and the
# co-occurrence fetch would both be dead work. _resolve_from_candidates routes every
# mention straight to its find-or-create path, which matches on LOWER(canonical_name)
# equality. A *mixed* batch still probes — the per-mention check below skips the
# literal names when scoring, which costs a little wasted lookup but keeps the
# common all-resolving case on one code path.
return await self._resolve_from_candidates(
conn,
bank_id,
entities_data,
unit_event_date,
all_candidates={},
cooccurrence_map={},
taxonomy_lookup=taxonomy_lookup,
labels_cfg=labels_cfg,
)
if self.entity_lookup == "trigram":
# Route to backend-specific fuzzy strategy.
# Non-PG backends (Oracle) use UTL_MATCH instead of pg_trgm.
@@ -872,7 +906,7 @@ class EntityResolver:
rep_by_lower: dict[str, str] = {}
count_by_lower: dict[str, int] = {}
for e in entities_to_create:
if e.is_label:
if e.is_label or not e.resolve:
continue
name_lower = e.name.lower()
rep_by_lower.setdefault(name_lower, e.name)
@@ -904,7 +938,12 @@ class EntityResolver:
taxonomy_lookup: set[str] | None = None,
labels_cfg=None,
) -> list[ResolvedEntity]:
"""Shared scoring + upsert logic used by both lookup strategies."""
"""Shared scoring + upsert logic used by every lookup strategy.
A mention carrying ``"resolve": False`` skips the scoring entirely and takes the
find-or-create path below, which matches an existing row on ``LOWER(canonical_name)``
equality and inserts one otherwise.
"""
# Resolve each entity using pre-fetched candidates. A slot stays None
# only if find-or-create fails to produce a row for a mention (a DB
@@ -923,6 +962,9 @@ class EntityResolver:
# Use per-entity date if available, otherwise fall back to batch-level date
entity_event_date = entity_data.get("event_date", unit_event_date)
# Per mention, not per batch: retain resolves the caller's entities and the
# extractor's in one pass, and only the caller's are meant literally (#3479).
resolve = entity_data.get("resolve", True)
candidates = all_candidates.get(entity_text, [])
# Backstop truncation for candidate sets that were not capped at the
@@ -951,10 +993,14 @@ class EntityResolver:
# classify by key prefix (see _label_texts).
is_label = bool(labels_cfg and _is_label_entity(entity_text, labels_cfg, taxonomy_lookup or set()))
if not candidates:
# Will create new entity
if not resolve or not candidates:
# Nothing to score against — or the caller named the entity literally, so
# similarity must not get a vote. Either way the find-or-create pass below
# reuses an identically-named row and otherwise inserts this exact name.
entities_to_create.append(
_EntityToCreate(idx=idx, name=entity_text, event_date=entity_event_date, is_label=is_label)
_EntityToCreate(
idx=idx, name=entity_text, event_date=entity_event_date, is_label=is_label, resolve=resolve
)
)
continue
@@ -1057,7 +1103,9 @@ class EntityResolver:
# variants (case/emoji/suffix/typo of one name) collapse to a single entity. Without
# this, resolution only compares against already-persisted rows, so the first sighting
# of each variant in a batch always creates a distinct entity (issue #3107). Labels are
# excluded and keep exact grouping.
# excluded and keep exact grouping, and so are names the caller wrote literally:
# "Alice" and "Alice Smith" listed side by side are two entities because they were
# written as two (#3479).
canonical_by_member = self._intrabatch_canonical_map(entities_to_create)
@dataclass
@@ -160,16 +160,22 @@ class MemoryEngineInterface(ABC):
async def list_banks(
self,
*,
search_query: str | None = None,
limit: int = 100,
offset: int = 0,
request_context: "RequestContext",
) -> list[dict[str, Any]]:
) -> dict[str, Any]:
"""
List all memory banks.
List memory banks, one page at a time.
Args:
search_query: Case-insensitive substring matched against bank ID and name.
limit: Maximum number of banks to return (0 returns none).
offset: Number of banks to skip.
request_context: Request context for authentication.
Returns:
List of bank info dicts.
Dict with ``banks`` (the page), ``total``, ``limit`` and ``offset``.
"""
...
@@ -322,7 +328,7 @@ class MemoryEngineInterface(ABC):
self,
bank_id: str,
*,
fact_type: str | None = None,
fact_type: str | list[str] | None = None,
search_query: str | None = None,
entity_id: str | None = None,
created_before: datetime | None = None,
@@ -335,7 +341,8 @@ class MemoryEngineInterface(ABC):
Args:
bank_id: The memory bank ID.
fact_type: Filter by fact type.
fact_type: Filter by fact type. A list matches any of them; an empty
list is treated as no filter.
search_query: Full-text search query.
entity_id: Filter to memory units linked to this entity ID.
created_before: Keep units with ``created_at`` before this instant.
@@ -1043,7 +1043,7 @@ class MemoriesExtension(Extension, ABC):
ops,
fq_table,
bank_id: str,
fact_type: str | None = None,
fact_type: str | list[str] | None = None,
search_query: str | None = None,
consolidation_state: str | None = None,
state: str | None = None,
@@ -84,7 +84,7 @@ async def list_memory_units(
ops,
fq_table,
bank_id: str,
fact_type: str | None = None,
fact_type: str | list[str] | None = None,
search_query: str | None = None,
consolidation_state: str | None = None,
state: str | None = None,
@@ -104,7 +104,8 @@ async def list_memory_units(
ops: Dialect ops. Unused by this query; part of the interface signature.
fq_table: Table-name resolver.
bank_id: Filter by bank ID
fact_type: Filter by fact type (world, experience)
fact_type: Filter by fact type (world, experience). A list matches any of
them; an empty list is treated as no filter.
search_query: Full-text search query (searches text and context fields)
document_id: Optional filter to a single source document.
tags: Optional list of tag names to filter by. When omitted, no tag
@@ -154,8 +155,14 @@ async def list_memory_units(
if fact_type:
param_count += 1
query_conditions.append(f"fact_type = ${param_count}")
query_params.append(fact_type)
if isinstance(fact_type, str):
query_conditions.append(f"fact_type = ${param_count}")
query_params.append(fact_type)
else:
# A list is "any of these" — one array parameter rather than an IN list
# whose placeholder count varies with the caller's argument.
query_conditions.append(f"fact_type = ANY(${param_count}::text[])")
query_params.append(list(fact_type))
if document_id:
param_count += 1
@@ -240,7 +247,8 @@ async def list_memory_units(
f"""
SELECT id, text, event_date, context, fact_type, document_id,
mentioned_at, occurred_start, occurred_end, chunk_id, proof_count,
tags, metadata, consolidated_at, consolidation_failed_at, edited_at, {curation_cols}
tags, metadata, consolidated_at, consolidation_failed_at, edited_at,
updated_at, source_memory_ids, {curation_cols}
FROM {source_table}
{where_clause}
ORDER BY mentioned_at DESC NULLS LAST, created_at DESC
@@ -304,6 +312,12 @@ async def list_memory_units(
"invalidation_reason": row["invalidation_reason"],
"invalidated_at": row["invalidated_at"].isoformat() if row["invalidated_at"] else None,
"edited_at": row["edited_at"].isoformat() if row["edited_at"] else None,
# Both come off the row already selected above, so neither adds a
# query: updated_at is the write watermark curation and freshness
# checks compare against, and source_memory_ids is an observation's
# lineage (empty for a source fact).
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
"source_memory_ids": [str(sid) for sid in row["source_memory_ids"] or []],
}
)
@@ -463,7 +477,7 @@ async def list_entities(
# Get paginated entities
rows = await conn.fetch(
f"""
SELECT id, canonical_name, mention_count, first_seen, last_seen, metadata
SELECT id, canonical_name, entity_kind, mention_count, first_seen, last_seen, metadata
FROM {fq_table("entities")}
WHERE {where_clause}
ORDER BY mention_count DESC, last_seen DESC, id ASC
@@ -490,6 +504,9 @@ async def list_entities(
{
"id": str(row["id"]),
"canonical_name": row["canonical_name"],
# How the entity was classified (label vs free-form, etc.); same row,
# so listing it costs nothing extra.
"entity_kind": row["entity_kind"],
"mention_count": row["mention_count"],
"first_seen": row["first_seen"].isoformat() if row["first_seen"] else None,
"last_seen": row["last_seen"].isoformat() if row["last_seen"] else None,
@@ -107,8 +107,8 @@ class PostgresMemories(MemoriesExtension):
The per-arm split is Postgres's own business, kept off the interface: this reproduces the
exact orchestration recall used before it was unified — one dense+BM25 UNION query and the
temporal query share a single connection, then the graph retriever runs per fact_type on the
pool in parallel, seeded by the same dense over-fetch. Result is byte-identical to running
the arms separately; fusion/rerank still happen downstream.
pool in parallel, seeded by the same dense results. Result is byte-identical to running the
arms separately; fusion/rerank still happen downstream.
"""
import asyncio
@@ -172,7 +172,7 @@ class PostgresMemories(MemoriesExtension):
)
# Graph per fact_type in parallel, on the pool, after the dense connection is released —
# seeded by the dense over-fetch (preselected_semantic_seeds), matching the prior path.
# seeded by the dense results (preselected_semantic_seeds), matching the prior path.
graph_by_ft: dict[str, list] = {ft: [] for ft in fact_types}
if enable_graph:
assert retriever is not None # only resolved when the arm is on
@@ -228,6 +228,14 @@ class PostgresMemories(MemoriesExtension):
min_keyword: float | None = None,
graph_seed_min_similarity: float | None = None,
) -> "dict[str, SemanticBm25Result]":
"""The dense + keyword arms, as one UNION query.
How deep the ANN scan goes is not decided here: the connection carries
``hnsw.iterative_scan``, which lets the scan resume until this query's own LIMIT
is met (see ``_ANN_TUNING_HIGH_RECALL``). Before that was enabled the scan
stopped at ``hnsw.ef_search`` rows — a fixed 200 — so a larger recall budget
widened the SQL and changed nothing.
"""
# Imported here: retrieval imports this package, so a module-level import
# would close the cycle.
from ..search.retrieval import retrieve_semantic_bm25_combined_sql
@@ -459,7 +467,7 @@ class PostgresMemories(MemoriesExtension):
ops,
fq_table,
bank_id: str,
fact_type: str | None = None,
fact_type: str | list[str] | None = None,
search_query: str | None = None,
consolidation_state: str | None = None,
state: str | None = None,
@@ -2513,6 +2513,13 @@ class MemoryEngine(MemoryEngineInterface):
)
logger.info(f"[CONSOLIDATION] bank={bank_id} completed: {result.get('memories_processed', 0)} processed")
# Consolidation is the other writer of memory_units rows: it mints
# observations, which are their own fact_type and so their own indexed
# partition. Retain's post-insert hook cannot see them — a bank whose
# observations crossed the threshold here would otherwise wait for an
# unrelated retain to notice (issue #3485).
await self._submit_vector_index_maintenance_quietly(bank_id, internal_context, after="consolidation")
return result
async def _handle_graph_maintenance(self, task_dict: dict[str, Any]):
@@ -2690,6 +2697,8 @@ class MemoryEngine(MemoryEngineInterface):
consolidation_result = await self._handle_consolidation(task_dict)
elif task_type == "graph_maintenance":
await self._handle_graph_maintenance(task_dict)
elif task_type == "vector_index_maintenance":
await self._handle_vector_index_maintenance(task_dict)
elif task_type == "refresh_mental_model":
await self._handle_refresh_mental_model(task_dict)
elif task_type == "webhook_delivery":
@@ -4621,11 +4630,16 @@ class MemoryEngine(MemoryEngineInterface):
* auto-consolidation (when observations + auto-consolidation are enabled
for the bank) so freshly inserted facts get observations;
* graph maintenance, which short-circuits when no cleanup work was
enqueued, so a plain insert pays a single cheap indexed SELECT here.
enqueued, so a plain insert pays a single cheap indexed SELECT here;
* per-bank vector index coverage, which short-circuits when the bank's
indexes already match its size. Inserts are what move a bank across
the size threshold, so this is where coverage is decided there is
nothing a periodic sweep could discover that the writer does not
already know (issue #3485).
Both are non-critical: failures are logged, never raised, so they can't
fail the operation that produced the facts. Pass ``config`` when the caller
already resolved it to avoid a redundant lookup.
All three are non-critical: failures are logged, never raised, so they
can't fail the operation that produced the facts. Pass ``config`` when the
caller already resolved it to avoid a redundant lookup.
"""
if config is None:
config = await self._config_resolver.resolve_full_config(bank_id, request_context)
@@ -4638,6 +4652,32 @@ class MemoryEngine(MemoryEngineInterface):
await self.submit_async_graph_maintenance(bank_id=bank_id, request_context=request_context)
except Exception as e:
logger.warning(f"Failed to submit graph maintenance task for bank {bank_id}: {e}")
await self._submit_vector_index_maintenance_quietly(bank_id, request_context, after="retain")
async def _submit_vector_index_maintenance_quietly(
self,
bank_id: str,
request_context: "RequestContext",
*,
after: str,
) -> None:
"""Queue a vector-index reconcile for ``bank_id``, swallowing failures.
Called from every path that changes how many memory_units rows a bank
holds inserts (retain, import), consolidation (which mints
observations) and deletes. Deletes matter as much as inserts: a bank
pruned back under the threshold keeps indexes it no longer earns, and
with nothing else scanning for that, an emptied bank that is never
written to again would carry them forever.
Never raises. Index coverage is an optimisation a bank without it
falls back to exact search so it must not be able to fail the delete or
retain that produced the change.
"""
try:
await self.submit_async_vector_index_maintenance(bank_id=bank_id, request_context=request_context)
except Exception as e:
logger.warning(f"Failed to submit vector index maintenance after {after} for bank {bank_id}: {e}")
async def _resolve_retain_config(
self,
@@ -7350,6 +7390,7 @@ class MemoryEngine(MemoryEngineInterface):
)
except Exception as e:
logger.warning(f"Failed to submit graph maintenance after document deletion for bank {bank_id}: {e}")
await self._submit_vector_index_maintenance_quietly(bank_id, request_context, after="document deletion")
return result
@@ -7708,6 +7749,9 @@ class MemoryEngine(MemoryEngineInterface):
f"Failed to submit graph maintenance after memory deletion "
f"for bank {bank_id_for_graph_maintenance}: {e}"
)
await self._submit_vector_index_maintenance_quietly(
bank_id_for_graph_maintenance, request_context, after="memory deletion"
)
return result
@@ -7892,6 +7936,7 @@ class MemoryEngine(MemoryEngineInterface):
)
except Exception as e:
logger.warning(f"Failed to submit graph maintenance after bulk memory deletion for bank {bank_id}: {e}")
await self._submit_vector_index_maintenance_quietly(bank_id, request_context, after="bulk memory deletion")
return {
"requested": len(unit_ids),
@@ -8125,6 +8170,19 @@ class MemoryEngine(MemoryEngineInterface):
except Exception as e:
logger.warning(f"Failed to submit consolidation after bank deletion for bank {bank_id}: {e}")
# A bank that survives this call (clear-memories, or a fact_type-scoped
# delete) has lost rows and may no longer earn the indexes it has —
# frequently all of them, since clearing a bank empties every partition.
# The full-delete path above already dropped them by name while it still
# knew the internal_id; this is the path where the bank stays, so the
# reconcile has to be asked. Without it an emptied-but-kept bank holds
# three ANN indexes over nothing until someone writes to it again, and
# an emptied bank is exactly the one nobody writes to again.
if not delete_bank_profile:
await self._submit_vector_index_maintenance_quietly(
bank_id, request_context, after="clearing bank memories"
)
return result
async def clear_observations(
@@ -8439,6 +8497,7 @@ class MemoryEngine(MemoryEngineInterface):
occurred_end: str | None = None,
new_fact_type: str | None = None,
entities: list[str] | None = None,
resolve_entities: bool = True,
state: str | None = None,
reason: str | None = None,
request_context: "RequestContext",
@@ -8456,9 +8515,18 @@ class MemoryEngine(MemoryEngineInterface):
observations + temporal/semantic links, and re-consolidates. For date/context fields,
``""`` clears to NULL and ``None`` leaves unchanged; ``new_fact_type``
must be world/experience. ``entities`` (when not None) replaces the
unit's entity set: names are resolved/find-or-created via the same
resolver retain uses, ``unit_entities`` + cooccurrence are rebuilt, and
``[]`` detaches all entities. Entities orphaned by the swap, and any
unit's entity set, ``unit_entities`` + cooccurrence are rebuilt, and
``[]`` detaches all entities. ``resolve_entities`` decides how those
names find their entities. When True (the default, and what retain
does) each name is resolved against the bank: a similar existing entity
that scores above the match threshold is reused. When False the names
are taken literally an existing entity is reused only on a
case-insensitive name match, any other name creates its own entity, and
same-request names are never merged with each other. Hand-authored
corrections want False: with resolution on, a similar-but-wrong entity
that is well connected to the other names in the same edit outscores
the one the caller named, and the correction lands on it silently
(#3479). Entities orphaned by the swap, and any
now-stale cooccurrence rows, are reclaimed by the graph-maintenance
sweep that this edit submits (entity edges live in ``unit_entities``,
not ``memory_links``, so there is nothing to relink directly).
@@ -8607,6 +8675,14 @@ class MemoryEngine(MemoryEngineInterface):
# resolve_entities_only find-or-creates the corrected entities (idempotent) and
# autocommits them on this short connection; the Phase-2 relink writes exactly
# this resolved set, keeping the stored embedding consistent with the links.
#
# resolve_entities decides whether these names are a correction or another
# guess (#3479). It defaults to True — retain's behaviour, kept as the default
# so existing callers are unaffected — under which a similar-but-wrong entity
# that is well-connected to the other names in this same list outscores the one
# the caller actually named, and the edit lands on it with a 200 and no warning.
# Callers correcting a fact by hand should pass False, which reuses an existing
# entity only on a case-insensitive name match.
entities_resolved = True
entity_resolution = await resolve_entities_only(
self.entity_resolver,
@@ -8616,7 +8692,7 @@ class MemoryEngine(MemoryEngineInterface):
[new_text],
new_context or "",
[entity_date],
[[{"text": name, "type": "CONCEPT"} for name in new_entities]],
[[{"text": name, "type": "CONCEPT", "resolve": resolve_entities} for name in new_entities]],
entity_labels=entity_labels,
)
resolved_for_unit = entity_resolution.unit_to_entity_ids.get(str(memory_uuid), [])
@@ -9383,7 +9459,7 @@ class MemoryEngine(MemoryEngineInterface):
self,
bank_id: str,
*,
fact_type: str | None = None,
fact_type: str | list[str] | None = None,
search_query: str | None = None,
consolidation_state: str | None = None,
state: str | None = None,
@@ -9401,7 +9477,9 @@ class MemoryEngine(MemoryEngineInterface):
Args:
bank_id: Filter by bank ID
fact_type: Filter by fact type (world, experience)
fact_type: Filter by fact type (world, experience). A list matches any
of them (e.g. ``['world', 'experience']`` for source facts); an
empty list is treated as no filter.
search_query: Full-text search query (searches text and context fields)
document_id: Optional filter to a single source document.
entity_id: Optional filter to memory units linked to this entity ID
@@ -11080,20 +11158,28 @@ class MemoryEngine(MemoryEngineInterface):
async def list_banks(
self,
*,
search_query: str | None = None,
limit: int = 100,
offset: int = 0,
request_context: "RequestContext",
) -> list[dict[str, Any]]:
) -> dict[str, Any]:
"""
List all agents in the system.
List memory banks, most recently written first.
Args:
search_query: Case-insensitive substring matched against bank ID and name.
limit: Maximum number of banks to return (0 returns none).
offset: Number of banks to skip.
request_context: Request context for authentication.
Returns:
List of dicts with bank_id, name, disposition, mission, created_at, updated_at
Dict with ``banks`` (one page of bank_id, name, disposition, mission,
created_at, updated_at and stats), ``total`` (banks matching the search
that are visible to the caller, before paging), ``limit`` and ``offset``.
"""
await self._authenticate_tenant(request_context)
await self._get_backend()
banks = await bank_utils.list_banks(self._backend)
banks = await bank_utils.list_banks(self._backend, search_query=search_query)
if self._operation_validator:
from hindsight_api.extensions import BankListContext
@@ -11101,18 +11187,31 @@ class MemoryEngine(MemoryEngineInterface):
BankListContext(banks=banks, request_context=request_context)
)
banks = result.banks
# Paging happens here rather than in SQL because filter_bank_list may drop any
# bank: a SQL page would hand back short (or empty) pages and a total counting
# banks the caller isn't allowed to see.
total = len(banks)
# Clamped because the page is a Python slice, not a SQL LIMIT: a negative value
# from a caller the HTTP layer doesn't validate (the MCP tool) would silently
# trim from the end instead of raising.
limit = max(limit, 0)
offset = max(offset, 0)
page = banks[offset : offset + limit]
# Per-bank work below is done for the returned page only — a live store count
# for banks whose memories live outside SQL, plus config resolution.
await bank_utils.apply_store_fact_counts(self._backend, page)
# Overlay resolved bank config (reflect_mission + disposition_*) on top of the
# legacy banks.disposition / banks.mission columns, mirroring get_bank_profile so
# the list and get paths return identical disposition + mission for a bank.
# Resolve every bank's config in one batch (single config-column query + a single
# Resolve the page's config in one batch (single config-column query + a single
# tenant-config resolve) rather than one round-trip per bank.
configs = await self._config_resolver.get_bank_configs([bank["bank_id"] for bank in banks], request_context)
for bank in banks:
configs = await self._config_resolver.get_bank_configs([bank["bank_id"] for bank in page], request_context)
for bank in page:
resolved = _overlay_bank_config_disposition_mission(
bank["disposition"], bank["mission"], configs.get(bank["bank_id"], {})
)
bank["disposition"], bank["mission"] = resolved.disposition, resolved.mission
return banks
return {"banks": page, "total": total, "limit": limit, "offset": offset}
# ==================== Reflect Methods ====================
@@ -16933,6 +17032,190 @@ class MemoryEngine(MemoryEngineInterface):
dedupe_excludes_operation_id=dedupe_excludes_operation_id,
)
async def submit_async_vector_index_maintenance(
self,
bank_id: str,
*,
request_context: "RequestContext",
dedupe_excludes_operation_id: str | None = None,
) -> dict[str, Any]:
"""Bring a bank's per-(bank, fact_type) vector indexes back in line with its size.
Called after a write that could have changed the bank's coverage —
retain, import, consolidation, curation. Only writes move a bank across
the threshold, so there is nothing for a periodic sweep to discover that
the writer did not already know; this replaces one.
Idempotent and self-limiting: it plans first and short-circuits with
``no_work=True`` when the bank's coverage already matches, so callers
can invoke it unconditionally without paying for an async_operations
row. At the default threshold of 0 that means one operation per
(bank, fact_type) at first write and silence forever after.
Deduplicates by bank against a job that is pending *or* already running:
the job re-plans from live row counts when it starts, so a job in flight
already covers a write that landed after it was queued.
A no-op on backends without per-bank indexes (ScaNN keeps one global
index) and on Oracle (partitioned by bank, no partial vector indexes).
"""
await self._authenticate_tenant(request_context)
index_clause = bank_utils._vector_index_clause()
if index_clause is None:
return {"operation_id": None, "no_work": True}
# Cheap pre-check: two bank-scoped index-only queries plus a catalog
# lookup. Mirrors submit_async_graph_maintenance — an unconditional
# caller must not create an empty worker task on every write.
from .vector_index_health import plan_bank_vector_indexes
backend = await self._get_backend()
try:
async with acquire_with_retry(backend) as conn:
plan = await plan_bank_vector_indexes(conn, get_current_schema(), bank_id)
except Exception as e:
# Planning is advisory: a bank whose coverage we could not read is
# picked up by the next write, or by `hindsight-admin repair-bank`.
logger.warning(f"Vector index planning failed for bank {bank_id}: {e}")
return {"operation_id": None, "no_work": True}
if plan.is_empty:
return {"operation_id": None, "no_work": True}
if self._operation_validator:
from hindsight_api.extensions import BankWriteContext, BankWriteOperation
ctx = BankWriteContext(
bank_id=bank_id,
operation=BankWriteOperation.SUBMIT_ASYNC_VECTOR_INDEX_MAINTENANCE,
request_context=request_context,
)
await self._validate_operation(self._operation_validator.validate_bank_write(ctx))
task_payload: dict[str, Any] = {}
if request_context.tenant_id:
task_payload["_tenant_id"] = request_context.tenant_id
if request_context.api_key_id:
task_payload["_api_key_id"] = request_context.api_key_id
return await self._submit_async_operation(
bank_id=bank_id,
operation_type="vector_index_maintenance",
task_type="vector_index_maintenance",
task_payload=task_payload,
dedupe_by_bank=True,
# Safe (unlike consolidation, which carries a watermark): the job
# re-plans from live row counts at start, so a running job already
# covers writes that landed after it was queued.
dedupe_by_bank_includes_processing=True,
# Set by the job's own hand-off so it does not match its own
# still-'processing' row and suppress its successor.
dedupe_excludes_operation_id=dedupe_excludes_operation_id,
)
async def _handle_vector_index_maintenance(self, task_dict: dict) -> None:
"""Reconcile one bank's vector indexes against the size threshold.
Runs on its own raw autocommit connection rather than a pooled one:
CREATE INDEX CONCURRENTLY cannot run inside a transaction block, and
both it and DROP INDEX CONCURRENTLY need a real backend session for the
whole statement. HINDSIGHT_API_MIGRATION_DATABASE_URL is preferred when
set, for the same reason migrations prefer it a transaction-pooled URL
cannot hold a session across the statement.
"""
import asyncpg
from ..pg0 import resolve_database_url
from .vector_index_health import reconcile_bank_vector_indexes
bank_id = task_dict.get("bank_id")
if not bank_id:
return
index_clause = bank_utils._vector_index_clause()
if index_clause is None:
return
# HINDSIGHT_API_MIGRATION_DATABASE_URL first when set — CREATE/DROP INDEX
# CONCURRENTLY needs a real backend session for the whole statement, which
# a transaction-pooled URL cannot give, and that env var is the documented
# direct-connection escape hatch (migrations use it for the same reason).
# Otherwise the DSN this engine is actually attached to, NOT
# config.database_url: the two differ whenever the engine was handed a DSN
# directly rather than reading the env var — embedders, and the test suite,
# which resolves pg0 in a fixture. Reading config there connected to a
# different database entirely and every reconcile died on
# `relation "public.banks" does not exist`.
backend = await self._get_backend()
url = get_config().migration_database_url or getattr(backend, "dsn", None)
if not url:
logger.debug("Vector index maintenance skipped: no database URL available")
return
from hindsight_api.models import RequestContext
request_context = RequestContext(
internal=True,
tenant_id=task_dict.get("_tenant_id"),
api_key_id=task_dict.get("_api_key_id"),
)
schema = get_current_schema()
conn = await asyncpg.connect(await resolve_database_url(url))
try:
result = await reconcile_bank_vector_indexes(conn, schema, bank_id, index_clause)
finally:
await conn.close()
if result.created or result.dropped or result.failed:
logger.info(
f"Vector index maintenance for bank {bank_id}: "
f"{result.created} built, {result.dropped} dropped, {result.failed} failed"
)
if result.failed:
# Logged, never raised. A failed build leaves the bank on the exact
# (bank_id, fact_type) B-tree path — slower on a large bank, but
# correct — so it is not worth failing the operation, running it back
# through the worker's retry/backoff, and surfacing a broken async op
# to the user. The usual cause is a transient deadlock against
# another session's concurrent index DDL on the shared memory_units
# table, and the next write to this bank re-queues the work anyway.
# `hindsight-admin repair-bank` is the path that treats a failed
# build as an error, because there a human is waiting on the answer.
logger.warning(
f"Vector index maintenance left {result.failed} index(es) unbuilt for bank {bank_id} "
f"({', '.join(result.failed_indexes)}); the next write to this bank retries"
)
return
# Hand off if the bank moved under us. The plan is a snapshot, and a
# multi-statement delete that is still committing when this job planned
# leaves it acting on a stale count — two jobs racing one delete can
# rebuild what the other just dropped. Nothing else is looking: with no
# periodic sweep, a bank that is never written again keeps whatever the
# last racing job decided. Same gap, and same fix, as graph maintenance's
# re-submit when work lands between its final claim and completion.
#
# Bounded two ways. The successor's own pre-check short-circuits once
# coverage matches, so a converged bank stops the chain; and this is
# skipped entirely when a build failed (returned above), so a permanently
# failing index cannot spin submits forever.
from .task_backend import SyncTaskBackend
# A synchronous task backend (tests, embedded) runs the successor inline
# and would recurse inside this handler; there the caller is serial
# anyway, so the next write reconciles.
if isinstance(self._task_backend, SyncTaskBackend):
return
try:
await self.submit_async_vector_index_maintenance(
bank_id=bank_id,
request_context=request_context,
dedupe_excludes_operation_id=task_dict.get("operation_id"),
)
except Exception:
# Never fail a completed reconcile over the hand-off; the next write
# picks it up. Logged loudly so a persistent failure is visible.
logger.exception(f"Vector index maintenance follow-up submit failed for bank {bank_id}")
async def submit_async_refresh_mental_model(
self,
bank_id: str,
@@ -45,36 +45,6 @@ def _vector_index_clause() -> str | None:
return index_using_clause(ext)
async def create_bank_vector_indexes(conn, bank_id: str, internal_id: str, ops=None) -> None:
"""Create per-(bank, fact_type) partial vector indexes for a newly created bank.
Respects the HINDSIGHT_API_VECTOR_EXTENSION config to use the appropriate
index type (HNSW for pgvector, DiskANN for pgvectorscale, vchordrq for vchord).
AlloyDB ScaNN uses global vector indexes with filtered vector search; it
cannot safely create per-bank indexes at bank-creation time because new
banks have no embedding rows.
bank_id is escaped for SQL literal safety (apostrophes doubled).
On Oracle 23ai, this is a no-op — Oracle uses a single global vector index
created during migrations. Partial indexes (WHERE clause) are not supported
for Oracle vector indexes.
"""
index_clause = _vector_index_clause()
if index_clause is None:
logger.debug("Skipping per-bank vector indexes for configured backend")
return
await ops.create_bank_vector_indexes(
conn,
fq_table("memory_units"),
bank_id,
internal_id,
index_clause,
_BANK_INDEX_FACT_TYPES,
)
async def drop_bank_vector_indexes(conn, internal_id: str, ops=None) -> None:
"""Drop per-(bank, fact_type) partial vector indexes for a bank being deleted.
@@ -190,12 +160,12 @@ async def get_or_create_bank_profile(pool, bank_id: str) -> BankProfileResult:
``get_or_create_bank_profile_on_conn`` instead.
"""
# A fresh bank builds its per-(bank, fact_type) partial vector indexes with
# a plain CREATE INDEX (it must — this runs inside the bank-create tx, and
# CONCURRENTLY cannot). That CREATE takes a ShareLock on the shared
# memory_units table, which can deadlock with concurrent writers. The build
# is idempotent (INSERT ... ON CONFLICT + CREATE INDEX IF NOT EXISTS), so a
# transient deadlock (40P01 / ORA-00060) is safe to retry as a whole tx.
# Retried as a whole transaction. This used to guard the per-bank CREATE
# INDEX that ran inline here and took a ShareLock on the shared memory_units
# table; that DDL is gone (#3485), but the lazy create can still lose a
# deadlock (40P01 / ORA-00060) to a concurrent writer touching the same
# bank row, and the body is idempotent (INSERT ... ON CONFLICT DO NOTHING),
# so retrying stays correct and cheap.
async def _create() -> BankProfileResult:
async with acquire_with_retry(pool) as conn:
async with conn.transaction():
@@ -242,10 +212,16 @@ async def get_or_create_bank_profile_on_conn(conn, bank_id: str, *, ops) -> Bank
created=False,
)
# Bank doesn't exist, create with defaults.
# Generate internal_id here so we control the value and can use it
# immediately for vector index creation without a RETURNING round-trip.
internal_id = uuid.uuid4()
# Bank doesn't exist, create with defaults. internal_id is minted here rather
# than defaulted server-side so its value is known without a RETURNING
# round-trip; the vector-index sweep derives index names from it.
#
# No vector-index DDL here. A fresh bank holds no rows, so it cannot meet
# the size threshold that earns a per-(bank, fact_type) partial index; the
# maintenance sweep builds one if and when the bank grows into it. Keeping
# DDL out of this path also takes CREATE INDEX's ShareLock on the shared
# memory_units table off the retain hot path, where it deadlocked against
# concurrent writers. See issue #3485.
inserted = await conn.fetchval(
f"""
INSERT INTO {fq_table("banks")} (bank_id, name, disposition, mission, internal_id)
@@ -257,14 +233,10 @@ async def get_or_create_bank_profile_on_conn(conn, bank_id: str, *, ops) -> Bank
bank_id, # Default name is the bank_id
json.dumps(DEFAULT_DISPOSITION),
"",
internal_id,
uuid.uuid4(),
)
created = inserted is not None
if created:
# Fresh insert — create per-bank vector indexes (instant on empty bank)
await create_bank_vector_indexes(conn, bank_id, str(internal_id), ops=ops)
return BankProfileResult(
profile=BankProfile(name=bank_id, disposition=DispositionTraits(**DEFAULT_DISPOSITION), mission=""),
created=created,
@@ -424,9 +396,9 @@ def _as_utc(ts: datetime | None) -> datetime | None:
return ts if ts.tzinfo is not None else ts.replace(tzinfo=UTC)
async def list_banks(pool) -> list:
async def list_banks(pool, *, search_query: str | None = None) -> list:
"""
List all banks in the system with summary stats.
List banks with summary stats, optionally narrowed by a search string.
``last_document_at`` is document *ingestion* time (when a document first
landed), while ``last_write_at`` is the last time anything was written to
@@ -434,8 +406,14 @@ async def list_banks(pool) -> list:
to a long-lived document does not move ``last_document_at``, which is why
the two differ and why UIs showing "last write" must use ``last_write_at``.
``fact_count`` comes from the ``memory_units`` join, which is empty for a bank
whose memories live outside SQL. Those banks need :func:`apply_store_fact_counts`
to get a real count; callers run it on the page they actually return so the live
per-bank count query doesn't fire for every bank in the system.
Args:
pool: Database connection pool
search_query: Case-insensitive substring matched against bank ID and name
Returns:
List of dicts with bank info and stats (fact_count, last_document_at, last_write_at),
@@ -445,6 +423,15 @@ async def list_banks(pool) -> list:
docs_table = fq_table("documents")
mu_table = fq_table("memory_units")
# Spelled out as UPPER(...) LIKE UPPER(...) rather than ILIKE: the Oracle
# rewriter only recognizes ILIKE on an unqualified column, and these are
# alias-qualified.
where_clause = ""
params: list[str] = []
if search_query:
where_clause = "WHERE (UPPER(b.bank_id) LIKE UPPER($1) OR UPPER(COALESCE(b.name, '')) LIKE UPPER($2))"
params = [f"%{search_query}%", f"%{search_query}%"]
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
f"""
@@ -470,19 +457,16 @@ async def list_banks(pool) -> list:
FROM {mu_table}
GROUP BY bank_id
) m ON m.bank_id = b.bank_id
{where_clause}
ORDER BY b.bank_id
"""
""",
*params,
)
result = []
# Banks are ordered by last write in Python rather than SQL: GREATEST() has
# different NULL semantics on PostgreSQL vs Oracle, and the bank list is small.
sort_keys: dict[str, datetime] = {}
# A store that keeps memories outside SQL leaves the memory_units join empty, so its
# per-bank fact_count comes from the store instead (one live count per bank).
from ..memories import get_memories
_store = get_memories()
for row in rows:
disposition_data = row["disposition"]
@@ -498,12 +482,6 @@ async def list_banks(pool) -> list:
write_times = [t for t in (_as_utc(row["last_document_write_at"]), _as_utc(row["last_fact_at"])) if t]
last_write = max(write_times) if write_times else None
fact_count = row["fact_count"]
if not _store.writes_memory_rows_in_sql_for(row["bank_id"]):
fact_count = sum(
(await _store.count_memories(conn=conn, fq_table=fq_table, bank_id=row["bank_id"])).values()
)
sort_keys[row["bank_id"]] = last_write or created_at or _UNIX_EPOCH
result.append(
{
@@ -513,7 +491,7 @@ async def list_banks(pool) -> list:
"mission": row["mission"] or "",
"created_at": created_at.isoformat() if created_at else None,
"updated_at": updated_at.isoformat() if updated_at else None,
"fact_count": fact_count,
"fact_count": row["fact_count"],
"last_document_at": last_doc.isoformat() if last_doc else None,
"last_write_at": last_write.isoformat() if last_write else None,
}
@@ -521,3 +499,23 @@ async def list_banks(pool) -> list:
result.sort(key=lambda bank: sort_keys[bank["bank_id"]], reverse=True)
return result
async def apply_store_fact_counts(pool, banks: list[dict]) -> None:
"""Replace ``fact_count`` in-place for banks that keep their memories outside SQL.
Those banks leave the ``memory_units`` join empty, so the count has to come
from the store — one live count per bank, which is why this runs on a single
page of :func:`list_banks` rather than on every bank in the system.
"""
from ..memories import get_memories
store = get_memories()
external = [bank for bank in banks if not store.writes_memory_rows_in_sql_for(bank["bank_id"])]
if not external:
return
async with acquire_with_retry(pool) as conn:
for bank in external:
counts = await store.count_memories(conn=conn, fq_table=fq_table, bank_id=bank["bank_id"])
bank["fact_count"] = sum(counts.values())
@@ -7,18 +7,23 @@ Handles entity extraction and resolution for stored facts.
import logging
from . import link_utils
from .types import EntityResolutionResult, ProcessedFact
from .types import EntityResolutionResult, ProcessedFact, UserEntities
logger = logging.getLogger(__name__)
def _prepare_facts_for_entity_processing(
facts: list[ProcessedFact],
user_entities_per_content: dict[int, list[dict]] | None = None,
user_entities_per_content: dict[int, UserEntities] | None = None,
) -> tuple[list[str], list, list[list[dict]]]:
"""
Extract fact texts, dates, and merged entity lists from ProcessedFact objects.
Extracted names always carry ``resolve=True`` — they are the extractor's guess at a name, so
matching them onto the bank's existing entities is the point. Caller-supplied names carry the
content item's ``resolve_entities`` flag, so a caller can have their own names taken literally
without turning off resolution for the extractor's (#3479).
Returns:
Tuple of (fact_texts, fact_dates, entities_per_fact)
"""
@@ -29,20 +34,29 @@ def _prepare_facts_for_entity_processing(
entities_per_fact = []
for fact in facts:
llm_entities = [{"text": entity.name, "type": "CONCEPT"} for entity in (fact.entities or [])]
llm_entities = [{"text": entity.name, "type": "CONCEPT", "resolve": True} for entity in (fact.entities or [])]
user_entities = user_entities_per_content.get(fact.content_index, [])
supplied = user_entities_per_content.get(fact.content_index)
user_entities = supplied.entities if supplied else []
user_resolve = supplied.resolve if supplied else True
seen_texts = {e["text"].lower() for e in llm_entities}
by_text = {e["text"].lower(): e for e in llm_entities}
for user_entity in user_entities:
if user_entity["text"].lower() not in seen_texts:
llm_entities.append(
{
"text": user_entity["text"],
"type": user_entity.get("type", "CONCEPT"),
}
)
seen_texts.add(user_entity["text"].lower())
text_lower = user_entity["text"].lower()
existing = by_text.get(text_lower)
if existing is None:
entity = {
"text": user_entity["text"],
"type": user_entity.get("type", "CONCEPT"),
"resolve": user_resolve,
}
llm_entities.append(entity)
by_text[text_lower] = entity
else:
# The extractor produced this name too. The caller still authored it, so their
# intent wins: a literal name must not become resolvable just because extraction
# happened to agree on the spelling.
existing["resolve"] = existing["resolve"] and user_resolve
entities_per_fact.append(llm_entities)
@@ -56,7 +70,7 @@ async def resolve_entities(
unit_ids: list[str],
facts: list[ProcessedFact],
log_buffer: list[str] = None,
user_entities_per_content: dict[int, list[dict]] = None,
user_entities_per_content: dict[int, UserEntities] | None = None,
entity_labels: list | None = None,
) -> EntityResolutionResult:
"""
@@ -72,7 +86,8 @@ async def resolve_entities(
unit_ids: Placeholder unit IDs (used only for grouping)
facts: List of ProcessedFact objects
log_buffer: Optional buffer for detailed logging
user_entities_per_content: Dict mapping content_index to user-provided entities
user_entities_per_content: Dict mapping content_index to the caller-supplied
entities for that content item and whether to resolve them
entity_labels: Optional entity label taxonomy
Returns:
@@ -13,7 +13,7 @@ from typing import Any
from ...config import _get_raw_config
from ..memory_engine import fq_table
from ..metadata_utils import drop_null_values
from .bank_utils import DEFAULT_DISPOSITION, create_bank_vector_indexes
from .bank_utils import DEFAULT_DISPOSITION
from .fact_extraction import _sanitize_text
from .types import ProcessedFact
@@ -132,25 +132,26 @@ async def ensure_bank_exists(conn, bank_id: str, ops=None) -> None:
conn: Database connection
bank_id: Bank identifier
"""
# Generate internal_id here so we control the value and can use it
# immediately for HNSW index creation without a RETURNING round-trip.
internal_id = uuid.uuid4()
inserted = await conn.fetchval(
# internal_id is generated here rather than defaulted server-side so the
# value is known without a RETURNING round-trip; the vector-index sweep
# derives index names from it.
#
# No vector-index DDL on this path. A fresh bank holds no rows, so it cannot
# meet the size threshold that earns a per-(bank, fact_type) partial index;
# the maintenance sweep builds one if the bank later grows into it. See
# issue #3485.
await conn.execute(
f"""
INSERT INTO {fq_table("banks")} (bank_id, name, disposition, mission, internal_id)
VALUES ($1, $2, $3::jsonb, $4, $5)
ON CONFLICT (bank_id) DO NOTHING
RETURNING bank_id
""",
bank_id,
bank_id, # Default name is the bank_id (matches get_or_create_bank_profile)
json.dumps(DEFAULT_DISPOSITION),
"",
internal_id,
uuid.uuid4(),
)
if inserted:
# Fresh insert — create per-bank vector indexes
await create_bank_vector_indexes(conn, bank_id, str(internal_id), ops=ops)
async def delete_stale_observations_for_memories(
@@ -17,6 +17,7 @@ from ..causal_links import (
)
from ..db.base import DatabaseConnection
from ..db.ops import DataAccessOps
from ..db.postgresql import setting_rejected_by_server
from ..memory_engine import fq_table
from .types import CausalRelation, EntityResolutionResult
@@ -43,6 +44,16 @@ def _normalize_entity_name(name: str) -> str:
return _WHITESPACE_RUN_RE.sub(" ", name).strip()
def _entity_resolve_flag(ent) -> bool:
"""Whether this candidate name should be resolved against existing entities.
Defaults to True (extraction's behaviour). Only dict candidates can opt out, which is how
retain marks the entities its *caller* supplied: those are authoritative names, not guesses
at which entity is meant (#3479).
"""
return bool(ent.get("resolve", True)) if isinstance(ent, dict) else True
# Maximum number of temporal links to keep per unit (from_unit_id).
# Retrieval only reads top 10-20 per unit via LATERAL join, so keeping
# more is wasted storage and write amplification.
@@ -213,7 +224,7 @@ def _prepare_entities_for_resolution(
# own entity list) identical, and the upstream dedup in
# entity_processing runs on the raw text. Without this, the same entity
# would be resolved twice for one fact and its mention_count bumped twice.
seen_in_fact: set[str] = set()
seen_in_fact: dict[str, dict] = {}
for ent in entity_list:
if hasattr(ent, "text"):
raw_text, entity_type = ent.text, "CONCEPT"
@@ -230,11 +241,19 @@ def _prepare_entities_for_resolution(
dropped_empty += 1
continue
if normalized_text.lower() in seen_in_fact:
resolve = _entity_resolve_flag(ent)
kept = seen_in_fact.get(normalized_text.lower())
if kept is not None:
# Same name after normalization. Keep the first spelling but carry the stricter
# flag: entity_processing dedups on the RAW text, so a caller's literal
# "Acme Corp" and the extractor's "Acme\nCorp" both reach here, and dropping the
# caller's outright would let the name be resolved away after all (#3479).
kept["resolve"] = kept["resolve"] and resolve
continue
seen_in_fact.add(normalized_text.lower())
formatted_entities.append({"text": normalized_text, "type": entity_type})
entity = {"text": normalized_text, "type": entity_type, "resolve": resolve}
seen_in_fact[normalized_text.lower()] = entity
formatted_entities.append(entity)
all_entities.append(formatted_entities)
if dropped_empty:
@@ -263,6 +282,7 @@ def _prepare_entities_for_resolution(
{
"text": entity["text"],
"type": entity["type"],
"resolve": entity["resolve"],
"nearby_entities": entities,
}
)
@@ -574,7 +594,14 @@ async def compute_semantic_links_ann(
# are safe to apply at session/transaction scope for the configured
# backend. VectorChord probe values are index-shaped, so vchordrq uses
# index storage fallback parameters instead of a blanket SET LOCAL.
#
# A GUC the server has already rejected is skipped rather than attempted:
# hnsw.iterative_scan needs pgvector 0.8+, and pgvector reserves the "hnsw."
# prefix, so an older server errors on it — which inside this transaction would
# abort the whole link computation rather than merely fail to apply.
for guc, value in ann_search_tuning_settings(configured_vector_extension(), kind="low_latency"):
if setting_rejected_by_server(guc):
continue
await conn.execute(f"SET LOCAL {guc} = {value}")
t_setup = time_mod.time()
@@ -282,6 +282,7 @@ from .types import (
ResolvedEntity,
RetainContent,
RetainContentDict,
UserEntities,
)
logger = logging.getLogger(__name__)
@@ -403,7 +404,11 @@ async def _pre_resolve_phase1(
set_stage("retain.phase1.resolve")
from .link_utils import compute_semantic_links_ann
user_entities_per_content = {idx: content.entities for idx, content in enumerate(contents) if content.entities}
user_entities_per_content = {
idx: UserEntities(entities=content.entities, resolve=content.resolve_entities)
for idx, content in enumerate(contents)
if content.entities
}
# Use placeholder unit_ids for grouping during resolution. The actual
# unit_ids are created later by insert_facts_batch inside the transaction,
@@ -1911,6 +1916,7 @@ async def _streaming_retain_batch(
event_date=source.event_date,
metadata=source.metadata,
entities=source.entities,
resolve_entities=source.resolve_entities,
tags=source.tags,
observation_scopes=source.observation_scopes,
)
@@ -3383,6 +3389,7 @@ def _build_contents(contents_dicts: list[RetainContentDict], document_tags: list
event_date=event_date_value,
metadata=item.get("metadata", {}),
entities=item.get("entities", []),
resolve_entities=item.get("resolve_entities", True),
tags=merged_tags,
observation_scopes=item.get("observation_scopes"),
)
@@ -3444,6 +3451,7 @@ def _build_delta_contents(
event_date=template_content.event_date,
metadata=template_content.metadata,
entities=template_content.entities,
resolve_entities=template_content.resolve_entities,
tags=template_content.tags,
observation_scopes=template_content.observation_scopes,
)
@@ -26,6 +26,8 @@ class RetainContentDict(TypedDict, total=False):
metadata: Custom key-value metadata (optional)
document_id: Document ID for this content item (optional)
entities: User-provided entities to merge with extracted entities (optional)
resolve_entities: Whether the supplied `entities` are resolved against the bank's
existing entities (optional, default True). False takes them literally.
tags: Visibility scope tags for this content item (optional)
observation_scopes: How to scope observations for consolidation (optional).
"per_tag" runs one pass per individual tag; "combined" (default) runs a
@@ -43,6 +45,7 @@ class RetainContentDict(TypedDict, total=False):
metadata: dict[str, str]
document_id: str
entities: list[dict[str, str]] # [{"text": "...", "type": "..."}]
resolve_entities: bool
tags: list[str] # Visibility scope tags
observation_scopes: (
Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]]
@@ -50,6 +53,18 @@ class RetainContentDict(TypedDict, total=False):
update_mode: Literal["replace", "append"]
@dataclass
class UserEntities:
"""The entities a caller supplied for one retain content item, and how to match them.
Kept together so the resolution choice travels with the names it applies to: retain merges
these with the extractor's own entities into one batch, and only these are authoritative.
"""
entities: list[dict[str, str]]
resolve: bool = True
@dataclass
class RetainContent:
"""
@@ -63,6 +78,9 @@ class RetainContent:
event_date: datetime | None = None
metadata: dict[str, str] = field(default_factory=dict)
entities: list[dict[str, str]] = field(default_factory=list) # User-provided entities
# Whether the supplied `entities` are matched against the bank's existing entities. False
# takes them literally; extracted entities are always resolved either way (#3479).
resolve_entities: bool = True
tags: list[str] = field(default_factory=list) # Visibility scope tags
observation_scopes: Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] | None = (
None # Observation scopes
@@ -147,10 +147,14 @@ async def retrieve_semantic_bm25_combined_sql(
idx_mu_emb_observation, idx_mu_emb_experience), created automatically by
Alembic migration a3b4c5d6e7f8_add_partial_hnsw_indexes.py.
HNSW is approximate semantic arms over-fetch by 5x (min 100) and trim to
limit in Python to compensate. ef_search=200 is set globally on pool
connections at init time (see memory_engine.py) to improve recall on sparse
graphs.
Each semantic arm asks for exactly ``limit`` rows. It used to ask for ``limit * 5``
and trim back to ``limit`` in Python "to compensate for HNSW approximation", but that
could never work: the rows arrive already ordered by distance within their arm, so
keeping the first ``limit`` of ``limit * 5`` returns precisely what ``LIMIT limit``
would have the extra rows were fetched, decoded and dropped, unread. What actually
governs ANN quality is the size of the candidate list the scan explores, which is a
connection setting, not a row count; the caller sizes it for this query (see
``PostgresMemories.search``) rather than over-fetching rows here.
fact_type values are inlined as literals (safe: they come from a controlled
internal enum, never from user input).
@@ -180,8 +184,17 @@ async def retrieve_semantic_bm25_combined_sql(
sem_min = min_semantic if min_semantic is not None else config.semantic_min_similarity
bm25_min = min_keyword if min_keyword is not None else config.bm25_min_score
# Over-fetch for HNSW approximation; semantic results trimmed to limit in Python.
hnsw_fetch = max(limit * 5, 100)
# How many semantic rows each arm must return. Two consumers read them: the semantic
# list itself (``limit``), and — when the dense rows also clear the graph arm's
# threshold — its entry points (``GRAPH_SEED_LIMIT``), derived from the same ordered
# rows instead of a duplicate ANN query per fact type. A budget below GRAPH_SEED_LIMIT
# would otherwise starve the graph arm of seeds.
graph_seed_threshold = (
graph_seed_min_similarity
if graph_seed_min_similarity is not None and sem_min <= graph_seed_min_similarity
else None
)
semantic_fetch = max(limit, GRAPH_SEED_LIMIT if graph_seed_threshold is not None else 0)
cols = (
"id, text, context, event_date, occurred_start, occurred_end, mentioned_at, "
@@ -199,7 +212,7 @@ async def retrieve_semantic_bm25_combined_sql(
# $1 = query_emb_str (semantic arms)
# $2 = bank_id
# When tokens present:
# $3 = limit (BM25 LIMIT; semantic uses inlined hnsw_fetch literal)
# $3 = limit (BM25 LIMIT; semantic inlines the same limit as a literal)
# $4 = bm25_text
# $5 = tags (if present)
# $6+ = tag_groups params (one per leaf)
@@ -242,7 +255,7 @@ async def retrieve_semantic_bm25_combined_sql(
fact_type=ft,
embedding_param="$1",
bank_id_param="$2",
fetch_limit=hnsw_fetch,
fetch_limit=semantic_fetch,
min_similarity=sem_min,
tags_clause=tags_clause,
groups_clause=groups_clause,
@@ -346,7 +359,7 @@ async def retrieve_semantic_bm25_combined_sql(
fact_type=ft,
embedding_param="$1",
bank_id_param="$2",
fetch_limit=hnsw_fetch,
fetch_limit=semantic_fetch,
min_similarity=sem_min,
tags_clause=fb_tags_clause,
groups_clause=fb_groups_clause,
@@ -364,17 +377,7 @@ async def retrieve_semantic_bm25_combined_sql(
else:
raise
# Group results. The semantic SQL deliberately over-fetches for HNSW recall;
# when that pool also covers the graph threshold, derive graph entry points
# from the same ordered rows instead of issuing one duplicate ANN query per
# fact type. Convert only the prefix either consumer can observe, not the
# entire HNSW over-fetch pool.
graph_seed_threshold = (
graph_seed_min_similarity
if graph_seed_min_similarity is not None and sem_min <= graph_seed_min_similarity
else None
)
semantic_candidate_limit = max(limit, GRAPH_SEED_LIMIT if graph_seed_threshold is not None else 0)
# Group results, converting only the prefix either consumer can observe.
semantic_candidates: dict[str, list[RetrievalResult]] = {ft: [] for ft in fact_types}
for r in rows:
row = dict(r)
@@ -383,7 +386,7 @@ async def retrieve_semantic_bm25_combined_sql(
if ft not in result_dict:
continue
if source == "semantic":
if len(semantic_candidates[ft]) < semantic_candidate_limit:
if len(semantic_candidates[ft]) < semantic_fetch:
semantic_candidates[ft].append(RetrievalResult.from_db_row(row))
else:
result_dict[ft].bm25.append(RetrievalResult.from_db_row(row))
@@ -374,7 +374,8 @@ class SQLDialect(ABC):
fact_type: Fact type literal (inlined, not parameterized).
embedding_param: Parameter placeholder for query embedding.
bank_id_param: Parameter placeholder for bank_id.
fetch_limit: Max rows to fetch (over-fetched for HNSW approximation).
fetch_limit: Max rows the arm returns. Its ANN candidate list must be at
least this wide or the scan cannot fill it see PostgresMemories.search.
min_similarity: Minimum cosine similarity to include.
tags_clause: Optional WHERE clause fragment for tag filtering.
groups_clause: Optional WHERE clause fragment for tag group filtering.
@@ -22,7 +22,7 @@ from typing import Any, Literal
from ..causal_links import CANONICAL_CAUSAL_LINK_TYPE, LEGACY_CAUSAL_LINK_TYPES
from ..db.ops_postgresql import pg_search_vector_expr
from ..db_utils import acquire_with_retry
from ..retain import bank_utils, chunk_storage, embedding_processing, fact_storage, link_utils, orchestrator
from ..retain import chunk_storage, embedding_processing, fact_storage, link_utils, orchestrator
from ..retain.types import (
CausalRelation,
ChunkMetadata,
@@ -559,16 +559,15 @@ async def import_bank(
parsed.bank_rows.get("banks", []),
bank_rows_json_encoding=bank_rows_json_encoding,
)
# The restored banks row bypasses the fresh-INSERT gate that normally
# creates per-bank vector indexes, so create them explicitly here while
# the bank is still empty (facts are imported below, so the build is
# instant). get_or_create_bank_profile would NOT do this: the row now
# exists, so it takes the SELECT branch and skips index creation —
# leaving the restored bank falling back to the global index +
# post-filter (slower, under-returning recall). See #2645.
internal_id = await conn.fetchval(f"SELECT internal_id FROM {fq_table('banks')} WHERE bank_id = $1", bank_id)
if internal_id is not None:
await bank_utils.create_bank_vector_indexes(conn, bank_id, str(internal_id), ops=ops)
# No vector-index DDL here. #2645 needed it because every bank was
# entitled to indexes and a restored bank bypassed the fresh-INSERT gate
# that created them; now entitlement is by size, and a restored bank's
# rows land through the normal import path where the maintenance sweep
# picks them up. Building inline would also be wrong twice over: the
# bank is empty at this point (the facts arrive below), and CREATE INDEX
# inside the import transaction takes a ShareLock on the shared
# memory_units table. An import large enough to deserve an index gets
# one on the next sweep. See #3485.
# Only now does the bank row — and with it the archive's own config — exist, so
# this is where the config the documents are replayed with has to come from.
@@ -1,16 +1,26 @@
"""Per-bank vector index coverage checks and repair.
"""Per-bank vector index coverage: what a bank should have, and making it so.
Per-(bank, fact_type) partial vector indexes are created only when a bank is
first created (instant on an empty bank). A bank that becomes *populated*
outside that fresh-INSERT path via a logical restore, a cross-version upgrade,
or a vector-extension switch (e.g. ScaNNpgvector) never gets them, so its
bank-scoped recall silently falls back to the global index + post-filter, which
is both slower and under-returns results. See issue #2645.
A (bank, fact_type) partition gets its own partial vector index once it holds
``HINDSIGHT_API_VECTOR_INDEX_MIN_ROWS`` rows. At the default of 0 that is every
partition holding any rows the behaviour before the threshold existed and a
deployment with thousands of banks raises it, because these indexes live on the
shared ``memory_units`` table: PostgreSQL locks and plans against every index on
a relation, and opens every one for each DML statement, so one bank's index is
charged to every other bank's queries. Three per bank exhausts the lock table at
a few thousand banks (issue #3485). Below the threshold the planner answers the
same query from the ``(bank_id, fact_type)`` B-tree plus a top-N sort, which is
exact rather than approximate and faster.
This module is the shared engine for detecting and repairing that gap. It is
driven by the ``repair-bank`` admin command; the build always uses
``CREATE INDEX CONCURRENTLY`` on a raw autocommit connection so it never takes
``ACCESS EXCLUSIVE`` on the shared ``memory_units`` table.
Nothing here runs on a request path. Index DDL is issued only by the
``vector_index_maintenance`` async operation (submitted after a write that could
have changed a bank's coverage) and by the ``repair-bank`` admin command, both
of which reconcile a bank against the plan this module computes.
All DDL is ``CREATE/DROP INDEX CONCURRENTLY`` on a raw autocommit connection, so
it never takes ``ACCESS EXCLUSIVE`` on the shared table. That is also what keeps
the drop path usable on an instance that has already hit the #3485 wall:
``DROP INDEX`` is a utility statement that locks its own index plus the table,
rather than planning against all of the table's indexes the way any DML must.
"""
from __future__ import annotations
@@ -19,6 +29,7 @@ import logging
from dataclasses import dataclass, field
from typing import Any
from .._vector_index import qualifies_for_per_bank_index, should_keep_per_bank_index
from .db_utils import retry_with_backoff
from .retain.bank_utils import _BANK_INDEX_FACT_TYPES, _bank_index_name
@@ -46,14 +57,38 @@ _SUPPORTED_INDEX_AM: tuple[str, ...] = (
@dataclass
class SchemaVectorIndexResult:
"""Per-schema outcome of a vector-index repair pass."""
class BankIndexPlan:
"""What one bank's vector-index coverage should become.
schema: str
banks_scanned: int = 0
Computed without issuing any DDL so the same plan can answer two questions:
"is there anything to do?" (the cheap pre-check that keeps every write from
queueing an empty operation) and "what exactly?" (the operation itself).
"""
bank_id: str
# fact_types at or above the build threshold whose index is missing or unhealthy.
to_build: list[str] = field(default_factory=list)
# Index names present in the catalog that this bank should no longer carry.
to_drop: list[str] = field(default_factory=list)
# Indexes already present and healthy — reported, never touched.
already_present: int = 0
@property
def is_empty(self) -> bool:
return not self.to_build and not self.to_drop
@dataclass
class BankIndexResult:
"""Outcome of applying a :class:`BankIndexPlan`."""
bank_id: str
created: int = 0
skipped: int = 0 # would-create, reported under --dry-run
dropped: int = 0
already_present: int = 0
# Would-create / would-drop, reported under dry_run.
skipped: int = 0
would_drop: int = 0
failed: int = 0
failed_indexes: list[str] = field(default_factory=list)
@@ -97,138 +132,238 @@ async def _index_health(conn: Any, schema: str, index_names: list[str]) -> dict[
return {row["index_name"]: bool(row["healthy"]) for row in rows}
async def _repair_schema(
async def plan_bank_vector_indexes(conn: Any, schema: str, bank_id: str) -> BankIndexPlan:
"""Work out what ``bank_id``'s vector-index coverage should become.
Two cheap, bank-scoped queries plus one catalog lookup, so the write path
can call this on every write to decide whether an operation is worth
queueing at all. The row count is an index-only scan of
``idx_memory_units_bank_fact_type``; deliberately unfiltered by
``embedding IS NOT NULL``, since that predicate is not in the index and
would turn the scan into a heap read without changing a threshold decision
by enough to matter.
A bank whose row is gone yields an empty plan: its indexes are dropped by
``delete_bank`` while the internal_id they are named after is still known,
and a bank-scoped reconcile has no way to name them afterwards.
"""
plan = BankIndexPlan(bank_id=bank_id)
qschema = _quote_identifier(schema)
internal_id = await conn.fetchval(
f"SELECT internal_id FROM {qschema}.banks WHERE bank_id = $1", # noqa: S608 — schema is a quoted identifier
bank_id,
)
if internal_id is None:
return plan
counts = {
row["fact_type"]: int(row["row_count"])
for row in await conn.fetch(
f"""
SELECT fact_type, COUNT(*) AS row_count
FROM {qschema}.memory_units
WHERE bank_id = $1 AND fact_type = ANY($2::text[])
GROUP BY fact_type
""", # noqa: S608 — schema is a quoted identifier
bank_id,
list(_BANK_INDEX_FACT_TYPES),
)
}
names = {ft: _bank_index_name(ft, str(internal_id)) for ft in _BANK_INDEX_FACT_TYPES}
health = await _index_health(conn, schema, list(names.values()))
for fact_type, index_name in names.items():
row_count = counts.get(fact_type, 0)
healthy = health.get(index_name)
if qualifies_for_per_bank_index(row_count):
if healthy is True:
plan.already_present += 1
else:
plan.to_build.append(fact_type)
elif healthy is not None and not should_keep_per_bank_index(row_count):
# Present but no longer earned. Keeping has its own, lower bound than
# building (see should_keep_per_bank_index) so a partition hovering
# at the threshold does not rebuild and drop the same ANN index on
# alternating writes.
plan.to_drop.append(index_name)
return plan
async def apply_bank_index_plan(
conn: Any,
schema: str,
index_clause: str,
plan: BankIndexPlan,
*,
dry_run: bool,
bank_id: str | None,
) -> SchemaVectorIndexResult:
result = SchemaVectorIndexResult(schema=schema)
dry_run: bool = False,
) -> BankIndexResult:
"""Build and drop what ``plan`` calls for, on a raw autocommit connection.
``conn`` must not be inside a transaction: ``CREATE INDEX CONCURRENTLY``
cannot run in one, and both it and ``DROP INDEX CONCURRENTLY`` need a real
backend session for the whole statement (a transaction-pooled URL will not
do that is what ``HINDSIGHT_API_MIGRATION_DATABASE_URL`` is for).
Concurrency is handled by idempotency, not a lock: the project forbids
advisory locks, which are unreliable behind connection poolers, and leaning
on one is why #2803's version of this was rejected. Every build is
``CREATE INDEX CONCURRENTLY IF NOT EXISTS`` guarded by a valid/ready health
check and every drop is ``DROP INDEX CONCURRENTLY IF EXISTS``, so a second
concurrent run is a no-op on work the first already did.
"""
result = BankIndexResult(bank_id=plan.bank_id, already_present=plan.already_present)
qschema = _quote_identifier(schema)
if bank_id is not None:
banks = await conn.fetch(
f"SELECT bank_id, internal_id FROM {qschema}.banks WHERE bank_id = $1", # noqa: S608 — schema is a quoted identifier
bank_id,
)
else:
banks = await conn.fetch(f"SELECT bank_id, internal_id FROM {qschema}.banks ORDER BY bank_id") # noqa: S608
result.banks_scanned = len(banks)
for index_name in plan.to_drop:
if dry_run:
result.would_drop += 1
continue
qualified = f"{qschema}.{_quote_identifier(index_name)}"
try:
await retry_with_backoff(
lambda qualified=qualified: conn.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {qualified}")
)
result.dropped += 1
except Exception as exc: # noqa: BLE001 — one failed drop must not abort the rest
result.failed += 1
result.failed_indexes.append(qualified)
logger.warning("Failed to drop stale vector index %s: %s", qualified, exc)
# Resolve expected index names for every bank, then check them all in one
# catalog query rather than one round-trip per index.
expected_by_bank: list[tuple[str, dict[str, str]]] = []
all_index_names: list[str] = []
for bank in banks:
expected = {ft: _bank_index_name(ft, str(bank["internal_id"])) for ft in _BANK_INDEX_FACT_TYPES}
expected_by_bank.append((bank["bank_id"], expected))
all_index_names.extend(expected.values())
health = await _index_health(conn, schema, all_index_names)
if not plan.to_build:
return result
for bid, expected in expected_by_bank:
# Render the bank_id literal server-side so escaping does not depend on
# standard_conforming_strings (the predicate is inlined into the DDL).
bank_id_literal = await conn.fetchval("SELECT quote_literal($1::text)", bid)
for ft in _BANK_INDEX_FACT_TYPES:
index_name = expected[ft]
healthy = health.get(index_name)
if healthy is True:
result.already_present += 1
continue
if dry_run:
result.skipped += 1
continue
# Render the bank_id literal server-side so escaping does not depend on
# standard_conforming_strings (the predicate is inlined into the DDL).
bank_id_literal = await conn.fetchval("SELECT quote_literal($1::text)", plan.bank_id)
internal_id = await conn.fetchval(
f"SELECT internal_id FROM {qschema}.banks WHERE bank_id = $1", # noqa: S608 — quoted identifier
plan.bank_id,
)
if internal_id is None:
# The bank was deleted between planning and applying; delete_bank has
# already dropped its indexes and there is nothing left to name.
return result
qindex = _quote_identifier(index_name)
qualified = f"{qschema}.{qindex}"
for fact_type in plan.to_build:
if dry_run:
result.skipped += 1
continue
qindex = _quote_identifier(_bank_index_name(fact_type, str(internal_id)))
qualified = f"{qschema}.{qindex}"
async def _rebuild(
qindex: str = qindex,
qualified: str = qualified,
ft: str = ft,
bank_id_literal: str = bank_id_literal,
) -> None:
# Always drop first. An unhealthy-but-present index (INVALID
# leftover, wrong access method) can't be repaired by
# IF NOT EXISTS, and a prior deadlocked CONCURRENTLY build leaves
# an INVALID stub that IF NOT EXISTS would likewise skip — so a
# retry must clear it. DROP ... IF EXISTS is a no-op when the
# index is simply absent (healthy is None).
await conn.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {qualified}")
await conn.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {qindex} "
f"ON {qschema}.memory_units {index_clause} "
f"WHERE fact_type = '{ft}' AND bank_id = {bank_id_literal}"
)
async def _rebuild(qindex: str = qindex, qualified: str = qualified, fact_type: str = fact_type) -> None:
# Always drop first. An unhealthy-but-present index (INVALID
# leftover, wrong access method) can't be repaired by IF NOT EXISTS,
# and a prior deadlocked CONCURRENTLY build leaves an INVALID stub
# that IF NOT EXISTS would likewise skip — so a retry must clear it.
# DROP ... IF EXISTS is a no-op when the index is simply absent.
await conn.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {qualified}")
await conn.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {qindex} "
f"ON {qschema}.memory_units {index_clause} "
f"WHERE fact_type = '{fact_type}' AND bank_id = {bank_id_literal}"
)
try:
# CREATE INDEX CONCURRENTLY on the live, concurrently-written
# memory_units table can be chosen as a deadlock victim (40P01).
# That is transient — Postgres aborts one side to break the cycle —
# so retry the drop+build before recording a permanent failure.
await retry_with_backoff(_rebuild)
result.created += 1
logger.info("Built vector index %s (bank=%s, fact_type=%s)", qualified, plan.bank_id, fact_type)
except Exception as exc: # noqa: BLE001 — one failed index must not abort the rest
result.failed += 1
result.failed_indexes.append(qualified)
logger.warning(
"Failed to build vector index %s (bank=%s, fact_type=%s): %s"
"dropping the invalid leftover so a re-run can retry.",
qualified,
plan.bank_id,
fact_type,
exc,
)
# A failed concurrent build leaves an INVALID index behind that
# would shadow the good one; drop it so a re-run retries cleanly.
try:
# CREATE INDEX CONCURRENTLY on the live, concurrently-written
# memory_units table can be chosen as a deadlock victim
# (sqlstate 40P01 / ORA-00060). That is transient — Postgres
# aborts one side to break the cycle — so retry the drop+build a
# few times before recording a permanent failure.
await retry_with_backoff(_rebuild)
result.created += 1
except Exception as exc: # noqa: BLE001 — one failed index must not abort the rest
result.failed += 1
result.failed_indexes.append(qualified)
logger.warning(
"Failed to repair vector index %s (bank=%s, fact_type=%s): %s"
"dropping the invalid leftover so a re-run can retry.",
qualified,
bid,
ft,
exc,
)
# A failed concurrent build leaves an INVALID index behind that
# would shadow the good one; drop it so a re-run retries cleanly.
try:
await conn.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {qualified}")
except Exception as cleanup_exc: # noqa: BLE001
logger.warning("Cleanup DROP INDEX for %s also failed: %s", qualified, cleanup_exc)
await conn.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {qualified}")
except Exception as cleanup_exc: # noqa: BLE001
logger.warning("Cleanup DROP INDEX for %s also failed: %s", qualified, cleanup_exc)
return result
async def _safe_repair_schema(
async def reconcile_bank_vector_indexes(
conn: Any,
schema: str,
index_clause: str,
*,
dry_run: bool,
bank_id: str | None,
) -> SchemaVectorIndexResult:
try:
return await _repair_schema(conn, schema, index_clause, dry_run=dry_run, bank_id=bank_id)
except Exception as exc: # noqa: BLE001 — one bad schema must not abort the whole sweep
logger.warning("Vector index repair aborted for schema %s: %s", schema, exc)
return SchemaVectorIndexResult(schema=schema, failed=1, failed_indexes=[f"{schema}.<schema-error>"])
async def repair_vector_indexes(
conn: Any,
schemas: list[str],
bank_id: str,
index_clause: str,
*,
dry_run: bool = False,
bank_id: str | None = None,
) -> list[SchemaVectorIndexResult]:
"""Rebuild missing or invalid per-bank vector indexes across ``schemas``.
) -> BankIndexResult:
"""Plan and apply one bank's vector-index coverage."""
plan = await plan_bank_vector_indexes(conn, schema, bank_id)
return await apply_bank_index_plan(conn, schema, index_clause, plan, dry_run=dry_run)
``conn`` must be a raw autocommit PostgreSQL connection: ``CREATE INDEX
CONCURRENTLY`` cannot run inside a transaction block. When ``bank_id`` is
given, only that bank is reconciled (in each schema); otherwise every bank
is scanned.
Concurrency is handled by idempotency, not a lock (project rule: no advisory
locks they are unreliable behind connection poolers). Every build is
``CREATE INDEX CONCURRENTLY IF NOT EXISTS`` guarded by a valid/ready health
check, so a second concurrent run is a no-op on already-built indexes; if two
runs race the *same* missing index, Postgres rejects one build and the
per-index handler drops the leftover so a re-run converges cleanly.
async def list_bank_ids(conn: Any, schema: str) -> list[str]:
"""Every bank in ``schema``, for the admin command's ``--all`` mode."""
rows = await conn.fetch(
f"SELECT bank_id FROM {_quote_identifier(schema)}.banks ORDER BY bank_id" # noqa: S608 — quoted identifier
)
return [row["bank_id"] for row in rows]
async def drop_orphaned_bank_indexes(conn: Any, schema: str, *, dry_run: bool = False) -> list[str]:
"""Drop per-bank vector indexes whose bank no longer exists.
``delete_bank`` drops a bank's indexes while the ``internal_id`` they are
named after is still known, so this should find nothing. It exists for when
that did not happen: a deployment that hit the #3485 wall could not run
``delete_bank`` at all (the delete DML could not plan), so operators dropped
banks by other means and left the indexes behind and an orphan is
unreachable by every bank-scoped path, because there is no bank row to plan
from.
Catalog-only, matching each index's name suffix against the live
``internal_id`` set, so it answers even on an instance whose lock table is
exhausted. Only the admin command calls it; the write path has no reason to.
"""
return [
await _safe_repair_schema(conn, schema, index_clause, dry_run=dry_run, bank_id=bank_id) for schema in schemas
]
qschema = _quote_identifier(schema)
live = {
str(row["internal_id"]).replace("-", "")[:16]
for row in await conn.fetch(f"SELECT internal_id FROM {qschema}.banks") # noqa: S608 — quoted identifier
}
rows = await conn.fetch(
"""
SELECT c.relname AS index_name
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_index i ON i.indexrelid = c.oid
JOIN pg_class t ON t.oid = i.indrelid
WHERE n.nspname = $1
AND t.relname = 'memory_units'
AND c.relname LIKE 'idx\\_mu\\_emb\\_%'
""",
schema,
)
orphans = [row["index_name"] for row in rows if row["index_name"].rsplit("_", 1)[-1] not in live]
if dry_run:
return orphans
dropped = []
for index_name in orphans:
qualified = f"{qschema}.{_quote_identifier(index_name)}"
try:
await retry_with_backoff(
lambda qualified=qualified: conn.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {qualified}")
)
dropped.append(index_name)
logger.info("Dropped orphaned vector index %s (no matching bank)", qualified)
except Exception as exc: # noqa: BLE001 — one failure must not abort the rest
logger.warning("Failed to drop orphaned vector index %s: %s", qualified, exc)
return dropped
@@ -390,6 +390,7 @@ class BankWriteOperation(StrEnum):
SET_BANK_MISSION = "set_bank_mission"
SUBMIT_ASYNC_CONSOLIDATION = "submit_async_consolidation"
SUBMIT_ASYNC_GRAPH_MAINTENANCE = "submit_async_graph_maintenance"
SUBMIT_ASYNC_VECTOR_INDEX_MAINTENANCE = "submit_async_vector_index_maintenance"
UPDATE_BANK = "update_bank"
UPDATE_BANK_CONFIG = "update_bank_config"
UPDATE_BANK_DISPOSITION = "update_bank_disposition"
+889 -5
View File
@@ -16,6 +16,7 @@ from mcp.types import ToolAnnotations
from pydantic import TypeAdapter
from hindsight_api import MemoryEngine
from hindsight_api.api import page_markdown
from hindsight_api.config import (
DEFAULT_MCP_RECALL_DESCRIPTION,
DEFAULT_MCP_RETAIN_DESCRIPTION,
@@ -65,6 +66,13 @@ _ALL_TOOLS: frozenset[str] = frozenset(
"update_bank",
"delete_bank",
"clear_memories",
"get_knowledge_base_tree",
"search_knowledge_base",
"get_knowledge_page",
"create_knowledge_folder",
"create_knowledge_page",
"update_knowledge_node",
"delete_knowledge_node",
}
)
@@ -228,6 +236,9 @@ _READ_ONLY_TOOLS = {
"list_operations",
"get_operation",
"list_tags",
"get_knowledge_base_tree",
"search_knowledge_base",
"get_knowledge_page",
}
_DESTRUCTIVE_TOOLS = {
"delete_bank",
@@ -237,6 +248,7 @@ _DESTRUCTIVE_TOOLS = {
"delete_directive",
"delete_document",
"invalidate_memory",
"delete_knowledge_node",
}
@@ -295,6 +307,13 @@ def register_mcp_tools(
"update_bank",
"delete_bank",
"clear_memories",
"get_knowledge_base_tree",
"search_knowledge_base",
"get_knowledge_page",
"create_knowledge_folder",
"create_knowledge_page",
"update_knowledge_node",
"delete_knowledge_node",
}
if "retain" in tools_to_register:
@@ -399,6 +418,28 @@ def register_mcp_tools(
if "clear_memories" in tools_to_register:
_register_clear_memories(mcp, memory, config)
# Knowledge base tools
if "get_knowledge_base_tree" in tools_to_register:
_register_get_knowledge_base_tree(mcp, memory, config)
if "search_knowledge_base" in tools_to_register:
_register_search_knowledge_base(mcp, memory, config)
if "get_knowledge_page" in tools_to_register:
_register_get_knowledge_page(mcp, memory, config)
if "create_knowledge_folder" in tools_to_register:
_register_create_knowledge_folder(mcp, memory, config)
if "create_knowledge_page" in tools_to_register:
_register_create_knowledge_page(mcp, memory, config)
if "update_knowledge_node" in tools_to_register:
_register_update_knowledge_node(mcp, memory, config)
if "delete_knowledge_node" in tools_to_register:
_register_delete_knowledge_node(mcp, memory, config)
_apply_bank_tool_filtering(mcp, memory, config)
_apply_audit_logging(mcp, memory, config)
@@ -509,6 +550,10 @@ _AUDITABLE_MCP_TOOLS: frozenset[str] = frozenset(
"delete_directive",
"delete_document",
"cancel_operation",
"create_knowledge_folder",
"create_knowledge_page",
"update_knowledge_node",
"delete_knowledge_node",
}
)
@@ -1211,19 +1256,30 @@ def _register_list_banks(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCon
"""Register the list_banks tool."""
@mcp.tool(annotations=_tool_annotations("list_banks"))
async def list_banks() -> str:
async def list_banks(query: str | None = None, limit: int = 100, offset: int = 0) -> str:
"""
List all available memory banks.
List available memory banks, most recently written first.
Use this tool to discover what memory banks exist in the system.
Each bank is an isolated memory store (like a separate "brain").
Args:
query: Optional case-insensitive substring to match against bank ID and name.
limit: Maximum number of banks to return (default 100).
offset: Number of banks to skip, for paging through `total`.
Returns:
JSON list of banks with their IDs, names, dispositions, and missions.
JSON with the page of banks (IDs, names, dispositions, missions) plus
the total number of matching banks and the limit/offset used.
"""
try:
banks = await memory.list_banks(request_context=_get_request_context(config))
return json.dumps({"banks": banks}, indent=2)
data = await memory.list_banks(
search_query=query,
limit=limit,
offset=offset,
request_context=_get_request_context(config),
)
return json.dumps(data, indent=2)
except OperationValidationError as e:
logger.warning(f"Operation rejected: {e}")
return json.dumps({"error": str(e), "banks": []})
@@ -2034,6 +2090,823 @@ def _register_clear_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCP
return {"error": str(e)}
# =========================================================================
# KNOWLEDGE BASE TOOLS
# =========================================================================
# A tree of folders and pages over mental models (see the HTTP
# /knowledge-base endpoints). Pages read as markdown documents; folders are
# containers. ``export_knowledge_base`` is deliberately NOT exposed here — it
# returns the whole bank as one markdown bundle, which belongs on the HTTP/CLI
# path rather than in an agent's context window.
# MCP tool arguments cannot express an explicit null: an omitted argument and a
# null one both arrive as ``None``, so update_knowledge_node reads this literal
# as "move to the top level". Node ids are prefixed (``kf-``/``kp-``), so it
# cannot collide with a real folder id.
KNOWLEDGE_ROOT_PARENT = "root"
def _knowledge_node_json(node: dict[str, Any]) -> dict[str, Any]:
"""Project an engine node dict into the compact JSON an MCP client sees.
Mirrors the HTTP ``KnowledgeNode`` projection, minus the fields an agent has
no use for (bank_id, sort_order): page metadata comes from the backing
mental model, folders carry structure only.
"""
is_page = node.get("kind") == "page"
projected: dict[str, Any] = {
"id": node["id"],
"kind": node["kind"],
"name": node["name"],
"parent_id": node.get("parent_id"),
"managed": bool(node.get("managed")),
}
if is_page:
projected["mental_model_id"] = node.get("mental_model_id")
projected["description"] = node.get("source_query")
projected["tags"] = list(node.get("tags") or [])
projected["timestamp"] = node.get("last_refreshed_at")
if node.get("trigger") is not None:
projected["trigger"] = node["trigger"]
if node.get("is_stale") is not None:
projected["is_stale"] = node["is_stale"]
else:
projected["timestamp"] = node.get("updated_at")
projected["children"] = []
return projected
def _knowledge_tree_json(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Assemble the flat node list into nested roots (mirrors the HTTP tree)."""
projected = {n["id"]: _knowledge_node_json(n) for n in nodes}
roots: list[dict[str, Any]] = []
for node in nodes:
parent_id = node.get("parent_id")
# Only folders can be parents (enforced on write), so the parent normally
# carries a children list; setdefault keeps a malformed row from raising.
if parent_id and parent_id in projected:
projected[parent_id].setdefault("children", []).append(projected[node["id"]])
else:
roots.append(projected[node["id"]])
return roots
def _page_trigger_patch(refresh_after_consolidation: bool | None) -> dict[str, Any] | None:
"""Build the trigger patch for the one refresh setting exposed over MCP.
The engine merges a patch over the page's defaults (create) or its current
trigger (update), so sending only this field leaves the rest delta mode,
observation-only facts as they were.
"""
if refresh_after_consolidation is None:
return None
return {"refresh_after_consolidation": refresh_after_consolidation}
async def _do_get_knowledge_base_tree(
memory: MemoryEngine, target_bank: str, request_context: RequestContext
) -> dict[str, Any]:
"""Shared implementation for the get_knowledge_base_tree MCP tool variants."""
nodes = await memory.list_knowledge_nodes(bank_id=target_bank, with_staleness=True, request_context=request_context)
return {"roots": _knowledge_tree_json(nodes)}
async def _do_search_knowledge_base(
memory: MemoryEngine, target_bank: str, request_context: RequestContext, *, query: str, limit: int
) -> dict[str, Any]:
"""Shared implementation for the search_knowledge_base MCP tool variants.
``limit`` is clamped rather than rejected: the HTTP route answers an
out-of-range limit with a 422, but an agent that asked for 500 pages wants
results, not a validation round trip.
"""
results = await memory.search_knowledge_pages(
bank_id=target_bank, query=query, limit=max(1, min(limit, 50)), request_context=request_context
)
return {"results": results, "total": len(results)}
async def _do_get_knowledge_page(
memory: MemoryEngine, target_bank: str, request_context: RequestContext, *, page_id: str
) -> dict[str, Any]:
"""Shared implementation for the get_knowledge_page MCP tool variants."""
node = await memory.get_knowledge_page(bank_id=target_bank, page_id=page_id, request_context=request_context)
if node is None:
return {"error": f"Knowledge page '{page_id}' not found in bank '{target_bank}'"}
page = page_markdown.page_type(node.get("tags"))
# The rendered document already carries the body under a frontmatter block,
# so it is returned once rather than alongside a duplicate `body` field.
return {
"id": node["id"],
"name": node["name"],
"type": page.type,
"description": node.get("source_query"),
"tags": page.display_tags,
"timestamp": node.get("last_refreshed_at") or node.get("created_at"),
"markdown": page_markdown.render_document(node),
}
async def _do_create_knowledge_folder(
memory: MemoryEngine, target_bank: str, request_context: RequestContext, *, name: str, parent_id: str | None
) -> dict[str, Any]:
"""Shared implementation for the create_knowledge_folder MCP tool variants."""
node = await memory.create_knowledge_folder(
bank_id=target_bank, name=name, parent_id=parent_id, request_context=request_context
)
return _knowledge_node_json(node)
async def _do_create_knowledge_page(
memory: MemoryEngine,
target_bank: str,
request_context: RequestContext,
*,
name: str,
source_query: str,
parent_id: str | None,
tags: list[str] | None,
max_tokens: int | None,
refresh_after_consolidation: bool | None,
) -> dict[str, Any]:
"""Shared implementation for the create_knowledge_page MCP tool variants."""
node = await memory.create_knowledge_page(
bank_id=target_bank,
name=name,
source_query=source_query,
content="Generating content...",
parent_id=parent_id,
tags=tags or None,
max_tokens=max_tokens,
trigger=_page_trigger_patch(refresh_after_consolidation),
request_context=request_context,
)
if node is None:
return {"error": f"A page named '{name}' already exists in this folder"}
result = await memory.submit_async_refresh_mental_model(
bank_id=target_bank, mental_model_id=node["mental_model_id"], request_context=request_context
)
return {
"page_id": node["id"],
"mental_model_id": node["mental_model_id"],
"operation_id": result["operation_id"],
"status": "created",
"message": f"Page '{name}' created. Content is being generated asynchronously.",
}
async def _do_update_knowledge_node(
memory: MemoryEngine,
target_bank: str,
request_context: RequestContext,
*,
node_id: str,
name: str | None,
parent_id: str | None,
source_query: str | None,
tags: list[str] | None,
max_tokens: int | None,
refresh_after_consolidation: bool | None,
) -> dict[str, Any]:
"""Shared implementation for the update_knowledge_node MCP tool variants.
Each field is applied only when provided, so a rename never resets a page's
query and moving a page never drops its tags.
"""
trigger = _page_trigger_patch(refresh_after_consolidation)
page_update = source_query is not None or tags is not None or max_tokens is not None or trigger is not None
if name is None and parent_id is None and not page_update:
return {
"error": "Provide name, parent_id, source_query, tags, max_tokens, "
"and/or refresh_after_consolidation to update"
}
updated: dict[str, Any] | None = None
if name is not None:
updated = await memory.rename_knowledge_node(
bank_id=target_bank, node_id=node_id, name=name, request_context=request_context
)
if parent_id is not None:
updated = await memory.move_knowledge_node(
bank_id=target_bank,
node_id=node_id,
new_parent_id=None if parent_id == KNOWLEDGE_ROOT_PARENT else parent_id,
request_context=request_context,
)
if page_update:
updated = await memory.update_knowledge_page(
bank_id=target_bank,
page_id=node_id,
source_query=source_query,
tags=tags,
max_tokens=max_tokens,
trigger=trigger,
request_context=request_context,
)
# A new source query means the page's content no longer answers it — rebuild.
if updated is not None and source_query is not None and updated.get("mental_model_id"):
await memory.submit_async_refresh_mental_model(
bank_id=target_bank, mental_model_id=updated["mental_model_id"], request_context=request_context
)
if updated is None:
return {"error": f"Knowledge node '{node_id}' not found in bank '{target_bank}'"}
return _knowledge_node_json(updated)
async def _do_delete_knowledge_node(
memory: MemoryEngine, target_bank: str, request_context: RequestContext, *, node_id: str
) -> dict[str, Any]:
"""Shared implementation for the delete_knowledge_node MCP tool variants."""
deleted = await memory.delete_knowledge_node(bank_id=target_bank, node_id=node_id, request_context=request_context)
if not deleted:
return {"error": f"Knowledge node '{node_id}' not found in bank '{target_bank}'"}
return {"status": "deleted", "node_id": node_id}
def _register_get_knowledge_base_tree(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the get_knowledge_base_tree tool."""
if config.include_bank_id_param:
@mcp.tool(annotations=_tool_annotations("get_knowledge_base_tree"))
async def get_knowledge_base_tree(
bank_id: str | None = None,
) -> str:
"""
Browse the knowledge base as a nested tree of folders and pages.
Start here to discover what the bank documents: each page is a living
markdown document synthesized from the bank's memories, and folders
group them. Use get_knowledge_page to read a page's content, or
search_knowledge_base when you know what you are looking for.
Pages report `is_stale`: false means the page is provably up to date;
true means something was written since its last refresh, so it MAY be
out of date.
Args:
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured"}'
tree = await _do_get_knowledge_base_tree(memory, target_bank, _get_request_context(config))
return json.dumps(tree, indent=2, default=str)
except OperationValidationError as e:
logger.warning(f"Operation rejected: {e}")
return json.dumps({"error": str(e)})
except Exception as e:
logger.error(f"Error getting knowledge base tree: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
else:
@mcp.tool(annotations=_tool_annotations("get_knowledge_base_tree"))
async def get_knowledge_base_tree() -> dict:
"""
Browse the knowledge base as a nested tree of folders and pages.
Start here to discover what the bank documents: each page is a living
markdown document synthesized from the bank's memories, and folders
group them. Use get_knowledge_page to read a page's content, or
search_knowledge_base when you know what you are looking for.
Pages report `is_stale`: false means the page is provably up to date;
true means something was written since its last refresh, so it MAY be
out of date.
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured"}
return await _do_get_knowledge_base_tree(memory, target_bank, _get_request_context(config))
except OperationValidationError as e:
logger.warning(f"Operation rejected: {e}")
return {"error": str(e)}
except Exception as e:
logger.error(f"Error getting knowledge base tree: {e}", exc_info=True)
return {"error": str(e)}
def _register_search_knowledge_base(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the search_knowledge_base tool."""
if config.include_bank_id_param:
@mcp.tool(annotations=_tool_annotations("search_knowledge_base"))
async def search_knowledge_base(
query: str,
limit: int = 10,
bank_id: str | None = None,
) -> str:
"""
Find knowledge pages by relevance (hybrid keyword + semantic search).
Searches page names and content, returning ranked pages with a short
snippet each. Read a hit in full with get_knowledge_page. This searches
the curated knowledge base only use recall to search raw memories.
Args:
query: What to search for
limit: Maximum pages to return (1-50, default: 10)
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured"}'
results = await _do_search_knowledge_base(
memory, target_bank, _get_request_context(config), query=query, limit=limit
)
return json.dumps(results, indent=2, default=str)
except OperationValidationError as e:
logger.warning(f"Operation rejected: {e}")
return json.dumps({"error": str(e)})
except Exception as e:
logger.error(f"Error searching knowledge base: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
else:
@mcp.tool(annotations=_tool_annotations("search_knowledge_base"))
async def search_knowledge_base(
query: str,
limit: int = 10,
) -> dict:
"""
Find knowledge pages by relevance (hybrid keyword + semantic search).
Searches page names and content, returning ranked pages with a short
snippet each. Read a hit in full with get_knowledge_page. This searches
the curated knowledge base only use recall to search raw memories.
Args:
query: What to search for
limit: Maximum pages to return (1-50, default: 10)
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured"}
return await _do_search_knowledge_base(
memory, target_bank, _get_request_context(config), query=query, limit=limit
)
except OperationValidationError as e:
logger.warning(f"Operation rejected: {e}")
return {"error": str(e)}
except Exception as e:
logger.error(f"Error searching knowledge base: {e}", exc_info=True)
return {"error": str(e)}
def _register_get_knowledge_page(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the get_knowledge_page tool."""
if config.include_bank_id_param:
@mcp.tool(annotations=_tool_annotations("get_knowledge_page"))
async def get_knowledge_page(
page_id: str,
bank_id: str | None = None,
) -> str:
"""
Read a knowledge page as a markdown document.
Returns the page's YAML frontmatter (id, type, title, description,
tags, timestamp) followed by its synthesized markdown body. Discover
page ids with get_knowledge_base_tree or search_knowledge_base.
Args:
page_id: The ID of the page to read (a `kp-...` node id)
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured"}'
page = await _do_get_knowledge_page(memory, target_bank, _get_request_context(config), page_id=page_id)
return json.dumps(page, indent=2, default=str)
except OperationValidationError as e:
logger.warning(f"Operation rejected: {e}")
return json.dumps({"error": str(e)})
except Exception as e:
logger.error(f"Error getting knowledge page: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
else:
@mcp.tool(annotations=_tool_annotations("get_knowledge_page"))
async def get_knowledge_page(
page_id: str,
) -> dict:
"""
Read a knowledge page as a markdown document.
Returns the page's YAML frontmatter (id, type, title, description,
tags, timestamp) followed by its synthesized markdown body. Discover
page ids with get_knowledge_base_tree or search_knowledge_base.
Args:
page_id: The ID of the page to read (a `kp-...` node id)
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured"}
return await _do_get_knowledge_page(memory, target_bank, _get_request_context(config), page_id=page_id)
except OperationValidationError as e:
logger.warning(f"Operation rejected: {e}")
return {"error": str(e)}
except Exception as e:
logger.error(f"Error getting knowledge page: {e}", exc_info=True)
return {"error": str(e)}
def _register_create_knowledge_folder(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the create_knowledge_folder tool."""
if config.include_bank_id_param:
@mcp.tool(annotations=_tool_annotations("create_knowledge_folder"))
async def create_knowledge_folder(
name: str,
parent_id: str | None = None,
bank_id: str | None = None,
) -> str:
"""
Create a folder in the knowledge base.
Folders group pages; they hold no content of their own.
Args:
name: Folder name
parent_id: Optional parent folder id (a `kf-...` node id). Omit to create at the top level.
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured"}'
node = await _do_create_knowledge_folder(
memory, target_bank, _get_request_context(config), name=name, parent_id=parent_id
)
return json.dumps(node, indent=2, default=str)
except OperationValidationError as e:
logger.warning(f"Operation rejected: {e}")
return json.dumps({"error": str(e)})
except ValueError as e:
return json.dumps({"error": str(e)})
except Exception as e:
logger.error(f"Error creating knowledge folder: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
else:
@mcp.tool(annotations=_tool_annotations("create_knowledge_folder"))
async def create_knowledge_folder(
name: str,
parent_id: str | None = None,
) -> dict:
"""
Create a folder in the knowledge base.
Folders group pages; they hold no content of their own.
Args:
name: Folder name
parent_id: Optional parent folder id (a `kf-...` node id). Omit to create at the top level.
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured"}
return await _do_create_knowledge_folder(
memory, target_bank, _get_request_context(config), name=name, parent_id=parent_id
)
except OperationValidationError as e:
logger.warning(f"Operation rejected: {e}")
return {"error": str(e)}
except ValueError as e:
return {"error": str(e)}
except Exception as e:
logger.error(f"Error creating knowledge folder: {e}", exc_info=True)
return {"error": str(e)}
def _register_create_knowledge_page(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the create_knowledge_page tool."""
if config.include_bank_id_param:
@mcp.tool(annotations=_tool_annotations("create_knowledge_page"))
async def create_knowledge_page(
name: str,
source_query: str,
parent_id: str | None = None,
tags: list[str] | None = None,
max_tokens: int | None = None,
refresh_after_consolidation: bool | None = None,
bank_id: str | None = None,
) -> str:
"""
Create a knowledge page a living document answering a question.
The page's content is synthesized from the bank's memories by running
source_query, asynchronously: use the returned operation_id to track
completion, then read it with get_knowledge_page. By default the page
keeps itself current, rebuilding after each consolidation.
EXAMPLES:
- name="Deployment Runbook", source_query="How is this service deployed and rolled back?"
- name="Team Preferences", source_query="What tools and conventions does the team prefer?"
Args:
name: Page name (must be unique within its folder)
source_query: The question this page answers and rebuilds itself from
parent_id: Optional parent folder id (a `kf-...` node id). Omit to create at the top level.
tags: Optional tags scoping which memories the page is built from
max_tokens: Maximum tokens for the generated content (default: 4096)
refresh_after_consolidation: Whether the page rebuilds itself after each memory
consolidation. Omit to keep the knowledge-page default (True).
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured"}'
result = await _do_create_knowledge_page(
memory,
target_bank,
_get_request_context(config),
name=name,
source_query=source_query,
parent_id=parent_id,
tags=tags,
max_tokens=max_tokens,
refresh_after_consolidation=refresh_after_consolidation,
)
return json.dumps(result, indent=2, default=str)
except OperationValidationError as e:
logger.warning(f"Operation rejected: {e}")
return json.dumps({"error": str(e)})
except ValueError as e:
return json.dumps({"error": str(e)})
except Exception as e:
logger.error(f"Error creating knowledge page: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
else:
@mcp.tool(annotations=_tool_annotations("create_knowledge_page"))
async def create_knowledge_page(
name: str,
source_query: str,
parent_id: str | None = None,
tags: list[str] | None = None,
max_tokens: int | None = None,
refresh_after_consolidation: bool | None = None,
) -> dict:
"""
Create a knowledge page a living document answering a question.
The page's content is synthesized from the bank's memories by running
source_query, asynchronously: use the returned operation_id to track
completion, then read it with get_knowledge_page. By default the page
keeps itself current, rebuilding after each consolidation.
EXAMPLES:
- name="Deployment Runbook", source_query="How is this service deployed and rolled back?"
- name="Team Preferences", source_query="What tools and conventions does the team prefer?"
Args:
name: Page name (must be unique within its folder)
source_query: The question this page answers and rebuilds itself from
parent_id: Optional parent folder id (a `kf-...` node id). Omit to create at the top level.
tags: Optional tags scoping which memories the page is built from
max_tokens: Maximum tokens for the generated content (default: 4096)
refresh_after_consolidation: Whether the page rebuilds itself after each memory
consolidation. Omit to keep the knowledge-page default (True).
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured"}
return await _do_create_knowledge_page(
memory,
target_bank,
_get_request_context(config),
name=name,
source_query=source_query,
parent_id=parent_id,
tags=tags,
max_tokens=max_tokens,
refresh_after_consolidation=refresh_after_consolidation,
)
except OperationValidationError as e:
logger.warning(f"Operation rejected: {e}")
return {"error": str(e)}
except ValueError as e:
return {"error": str(e)}
except Exception as e:
logger.error(f"Error creating knowledge page: {e}", exc_info=True)
return {"error": str(e)}
def _register_update_knowledge_node(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the update_knowledge_node tool."""
if config.include_bank_id_param:
@mcp.tool(annotations=_tool_annotations("update_knowledge_node"))
async def update_knowledge_node(
node_id: str,
name: str | None = None,
parent_id: str | None = None,
source_query: str | None = None,
tags: list[str] | None = None,
max_tokens: int | None = None,
refresh_after_consolidation: bool | None = None,
bank_id: str | None = None,
) -> str:
"""
Rename or move a folder/page, and/or update a page's options.
Only the arguments you pass are changed; everything else keeps its
current value. Changing source_query schedules an async refresh so the
page rebuilds against the new question.
Args:
node_id: The ID of the folder (`kf-...`) or page (`kp-...`) to update
name: New name for the node
parent_id: Folder id to move the node into, or "root" to move it to the top level
source_query: Pages only the new question the page answers
tags: Pages only replacement tag list (pass [] to clear)
max_tokens: Pages only new maximum tokens for the generated content
refresh_after_consolidation: Pages only whether the page rebuilds itself
after each memory consolidation
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured"}'
result = await _do_update_knowledge_node(
memory,
target_bank,
_get_request_context(config),
node_id=node_id,
name=name,
parent_id=parent_id,
source_query=source_query,
tags=tags,
max_tokens=max_tokens,
refresh_after_consolidation=refresh_after_consolidation,
)
return json.dumps(result, indent=2, default=str)
except OperationValidationError as e:
logger.warning(f"Operation rejected: {e}")
return json.dumps({"error": str(e)})
except ValueError as e:
return json.dumps({"error": str(e)})
except Exception as e:
logger.error(f"Error updating knowledge node: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
else:
@mcp.tool(annotations=_tool_annotations("update_knowledge_node"))
async def update_knowledge_node(
node_id: str,
name: str | None = None,
parent_id: str | None = None,
source_query: str | None = None,
tags: list[str] | None = None,
max_tokens: int | None = None,
refresh_after_consolidation: bool | None = None,
) -> dict:
"""
Rename or move a folder/page, and/or update a page's options.
Only the arguments you pass are changed; everything else keeps its
current value. Changing source_query schedules an async refresh so the
page rebuilds against the new question.
Args:
node_id: The ID of the folder (`kf-...`) or page (`kp-...`) to update
name: New name for the node
parent_id: Folder id to move the node into, or "root" to move it to the top level
source_query: Pages only the new question the page answers
tags: Pages only replacement tag list (pass [] to clear)
max_tokens: Pages only new maximum tokens for the generated content
refresh_after_consolidation: Pages only whether the page rebuilds itself
after each memory consolidation
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured"}
return await _do_update_knowledge_node(
memory,
target_bank,
_get_request_context(config),
node_id=node_id,
name=name,
parent_id=parent_id,
source_query=source_query,
tags=tags,
max_tokens=max_tokens,
refresh_after_consolidation=refresh_after_consolidation,
)
except OperationValidationError as e:
logger.warning(f"Operation rejected: {e}")
return {"error": str(e)}
except ValueError as e:
return {"error": str(e)}
except Exception as e:
logger.error(f"Error updating knowledge node: {e}", exc_info=True)
return {"error": str(e)}
def _register_delete_knowledge_node(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Register the delete_knowledge_node tool."""
if config.include_bank_id_param:
@mcp.tool(annotations=_tool_annotations("delete_knowledge_node"))
async def delete_knowledge_node(
node_id: str,
bank_id: str | None = None,
) -> str:
"""
Delete a knowledge-base folder or page and everything under it.
Deleting a folder also deletes its whole subtree, and each deleted page
takes its backing mental model with it. This cannot be undone.
Args:
node_id: The ID of the folder (`kf-...`) or page (`kp-...`) to delete
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
if target_bank is None:
return '{"error": "No bank_id configured"}'
result = await _do_delete_knowledge_node(
memory, target_bank, _get_request_context(config), node_id=node_id
)
return json.dumps(result)
except OperationValidationError as e:
logger.warning(f"Operation rejected: {e}")
return json.dumps({"error": str(e)})
except Exception as e:
logger.error(f"Error deleting knowledge node: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
else:
@mcp.tool(annotations=_tool_annotations("delete_knowledge_node"))
async def delete_knowledge_node(
node_id: str,
) -> dict:
"""
Delete a knowledge-base folder or page and everything under it.
Deleting a folder also deletes its whole subtree, and each deleted page
takes its backing mental model with it. This cannot be undone.
Args:
node_id: The ID of the folder (`kf-...`) or page (`kp-...`) to delete
"""
try:
target_bank = config.bank_id_resolver()
if target_bank is None:
return {"error": "No bank_id configured"}
return await _do_delete_knowledge_node(
memory, target_bank, _get_request_context(config), node_id=node_id
)
except OperationValidationError as e:
logger.warning(f"Operation rejected: {e}")
return {"error": str(e)}
except Exception as e:
logger.error(f"Error deleting knowledge node: {e}", exc_info=True)
return {"error": str(e)}
# =========================================================================
# DIRECTIVE TOOLS
# =========================================================================
@@ -2494,6 +3367,13 @@ def _register_update_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
all). The memory is re-embedded and its derived observations, links, and
graph are recomputed automatically.
resolve_entities controls how the names in entities are matched. The
default True behaves like retain and may resolve a name onto a similar
entity that already exists, which silently discards a correction when the
bank holds a near-duplicate name. Pass False whenever you are correcting a
fact deliberately: an existing entity is then reused only on a
case-insensitive name match, and any other name becomes its own entity.
Only raw world/experience facts can be edited; observations are derived.
To retire or restore a fact, use invalidate_memory instead.
"""
@@ -2509,6 +3389,7 @@ def _register_update_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
occurred_end: str | None = None,
fact_type: str | None = None,
entities: list[str] | None = None,
resolve_entities: bool = True,
bank_id: str | None = None,
) -> str:
"""
@@ -2530,6 +3411,7 @@ def _register_update_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
occurred_end=occurred_end,
new_fact_type=fact_type,
entities=entities,
resolve_entities=resolve_entities,
request_context=_get_request_context(config),
)
if result is None:
@@ -2555,6 +3437,7 @@ def _register_update_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
occurred_end: str | None = None,
fact_type: str | None = None,
entities: list[str] | None = None,
resolve_entities: bool = True,
) -> dict:
"""
Args:
@@ -2574,6 +3457,7 @@ def _register_update_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
occurred_end=occurred_end,
new_fact_type=fact_type,
entities=entities,
resolve_entities=resolve_entities,
request_context=_get_request_context(config),
)
if result is None:
@@ -638,11 +638,15 @@ def ensure_vector_extension(
if table_name == "memory_units" and uses_per_bank_vector_indexes(target_ext):
# Per-bank backends never use a GLOBAL memory_units vector index.
# Every vector search is bank + fact_type scoped and served by the
# per-(bank, fact_type) partial indexes created at bank-creation time
# (bank_utils.create_bank_vector_indexes); the planner never picks a
# global index when bank_id is in the WHERE clause, which is exactly
# why migration d5e6f7a8b9c0 drops it for these backends.
# Every vector search is bank + fact_type scoped, and is served
# either by the bank's own partial index or — for a bank below
# the size threshold, which is most of them — by an exact
# (bank_id, fact_type) B-tree scan plus a top-N sort. The planner
# never picks a global index when bank_id is in the WHERE clause,
# which is exactly why migration d5e6f7a8b9c0 drops it for these
# backends. The partial indexes themselves are owned by the
# maintenance sweep (engine/vector_index_health.py), not by this
# reconcile and not by bank creation.
#
# The reconcile is strictly hands-off here, in BOTH directions:
# never create the index (dead weight — an older version of this
+1
View File
@@ -189,6 +189,7 @@ markers = [
"hs_llm_core: Core pipeline tests that need a real LLM but only one provider",
"integration: Live external-API integration tests (require provider credentials; skipped without)",
"slow: Slow tests (minutes); not run in fast CI",
"memory_backend_incompatible: asserts Postgres-internal state a non-SQL memories backend cannot reproduce — raw memory_links row counts (the graph read path dedupes bidirectional edges), or internal columns like embedding / search_vector that are not part of the public read model. Deselect when running against an alternative memories backend with -m 'not memory_backend_incompatible'.",
]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
+30 -1
View File
@@ -116,10 +116,31 @@ DEFAULT_PG0_PORT = int(os.environ.get("HINDSIGHT_TEST_PG_PORT", "5556"))
# no job enabled, so the loop never starts. Tests that exercise it call
# MaintenanceLoop methods (_run_reconcile / _run_scheduled_mm_refresh /
# _purge_expired) directly.
#
# Every job added to the loop must be switched off here too: one job left on is
# enough to start the loop for the whole suite, which reintroduces exactly the
# races the others are disabled to avoid.
os.environ.setdefault("HINDSIGHT_API_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS", "0")
os.environ.setdefault("HINDSIGHT_API_MENTAL_MODEL_REFRESH_TICK_SECONDS", "0")
os.environ.setdefault("HINDSIGHT_API_LLM_TRACE_RETENTION_DAYS", "-1")
# Keep incidental per-bank vector-index DDL out of the suite. The shipped default
# is 0 — every bank holding rows earns its three indexes — which is right for a
# deployment whose banks are long-lived and get built once, but wrong here: the
# suite creates thousands of throwaway banks and writes one or two facts to each,
# so every one of them would queue a build. Eight xdist workers issuing
# CREATE INDEX CONCURRENTLY against a single shared memory_units deadlock each
# other by design (CONCURRENTLY holds ShareUpdateExclusive while it waits out
# every session whose snapshot could still see the index, including other
# sessions' index DDL), and that lands on whatever unrelated test happens to be
# writing at the time.
#
# A threshold no test bank can reach means the coverage machinery is inert unless
# a test asks for it: tests that exercise it patch the threshold themselves (see
# the low_threshold / default_threshold fixtures in
# test_repair_bank_vector_indexes.py).
os.environ.setdefault("HINDSIGHT_API_VECTOR_INDEX_MIN_ROWS", "1000000")
# Load environment variables from .env at the start of test session
def pytest_configure(config):
@@ -359,9 +380,17 @@ def oracle_db_url(_oracle_admin_dsn):
f'CREATE USER {test_user} IDENTIFIED BY "{test_pass}" DEFAULT TABLESPACE USERS QUOTA UNLIMITED ON USERS'
)
except oracledb.DatabaseError as e:
if hasattr(e.args[0], "code") and e.args[0].code == 1920:
code = getattr(e.args[0], "code", None)
if code == 1920:
# ORA-01920: user name conflicts with another user or role name
pass
elif code == 1031:
# ORA-01031: we are not an admin. CI provisions the user with a
# privileged account before pytest runs and then points
# ORACLE_TEST_DSN at that same unprivileged user, so this bootstrap
# cannot (and need not) create it. Assume it exists — if it does
# not, run_migrations below fails with a plain login error.
pass
else:
raise
@@ -233,6 +233,7 @@ async def test_backup_restore_roundtrip(backup_test_schema):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_backup_restore_preserves_all_column_types(backup_test_schema):
"""Test that all column types are preserved: vectors, UUIDs, timestamps, JSONB."""
db_url, schema_name, _fq, embeddings = backup_test_schema
+2 -1
View File
@@ -90,7 +90,8 @@ class TestAgentProfile:
await memory.get_bank_profile(agent_id_2, request_context=request_context)
await memory.get_bank_profile(agent_id_3, request_context=request_context)
agents = await memory.list_banks(request_context=request_context)
page = await memory.list_banks(search_query="test_list", limit=1000, request_context=request_context)
agents = page["banks"]
agent_ids = [a["bank_id"] for a in agents]
assert agent_id_1 in agent_ids
@@ -0,0 +1,319 @@
"""ANN scan depth follows each query's LIMIT, instead of a fixed candidate list.
An ANN scan explores a bounded candidate list and returns what it found, so that list
not the SQL LIMIT decided how many rows a recall arm could come back with. On
pgvector it is ``hnsw.ef_search``, pinned at 200 for the connection's lifetime by the
pool's init callback, which silently capped every recall at ~200 dense candidates
however large the budget: the budget moved the SQL and nothing else.
``hnsw.iterative_scan`` (pgvector 0.8+) resolves that without a per-query statement.
With it on, a drained candidate list is refilled in ``ef_search``-sized rounds until
the query's LIMIT is met, so depth follows the budget on the connection settings the
pool already applies which matters behind a transaction-mode pooler, where a
session GUC issued between statements can land on a different backend.
Covers:
- Both tuning profiles: recall resumes, retain-side link probing explicitly does not.
- That the arms fetch what both their consumers read (the semantic list, and the graph
arm's seeds) and that ``search`` issues no session statement of its own.
"""
from __future__ import annotations
import random
import uuid
from types import SimpleNamespace
import pytest
from hindsight_api._vector_index import ann_max_scan_tuples, ann_search_tuning_settings
from hindsight_api.engine.memories.postgres import PostgresMemories
from hindsight_api.engine.search import retrieval as retrieval_mod
from hindsight_api.engine.search.link_expansion_retrieval import GRAPH_SEED_LIMIT
BUDGET_MID = 300
# ---------------------------------------------------------------------------
# Tuning profiles
# ---------------------------------------------------------------------------
def test_recall_connections_resume_the_scan():
"""Without this the scan stops at ef_search rows and the budget cannot reach the index."""
settings = dict(ann_search_tuning_settings("pgvector", kind="high_recall"))
assert settings["hnsw.iterative_scan"] == "strict_order"
# relaxed_order would return rows out of distance order, which the Python-side
# trim in retrieve_semantic_bm25_combined_sql assumes it can rely on.
assert settings["hnsw.ef_search"] == "200"
# Bounded, so a heavily filtered query cannot resume its way into a huge scan.
assert settings["hnsw.max_scan_tuples"] == str(ann_max_scan_tuples())
assert ann_max_scan_tuples() < 20000 # pgvector's default
def test_retain_link_probing_does_not_resume():
"""Link probing is tuned for latency; resuming past its small list would defeat that."""
settings = dict(ann_search_tuning_settings("pgvector", kind="low_latency"))
assert settings["hnsw.iterative_scan"] == "off"
assert settings["hnsw.ef_search"] == "60"
def test_backends_without_the_knobs_get_no_settings():
for ext in ("vchord", "pgvectorscale", "pg_diskann", "scann"):
assert ann_search_tuning_settings(ext, kind="high_recall") == ()
assert ann_search_tuning_settings(ext, kind="low_latency") == ()
# ---------------------------------------------------------------------------
# What the arms ask for
# ---------------------------------------------------------------------------
class FakeDialect:
"""Captures what each semantic arm asks the index for."""
def __init__(self):
self.fetch_limits: list[int] = []
def build_semantic_arm(self, *, fetch_limit, **kwargs):
self.fetch_limits.append(fetch_limit)
return "SELECT 'semantic' AS source"
def build_bm25_arm(self, **kwargs):
return "SELECT 'bm25' AS source"
def prepare_bm25_text(self, tokens, query_text, **kwargs):
return " | ".join(tokens)
class FakeConn:
"""Fails the test if recall issues a session setting or opens a transaction."""
backend_type = "postgresql"
def transaction(self):
raise AssertionError("recall must not open a transaction to tune the scan")
async def execute(self, sql, *params):
raise AssertionError(f"recall must not issue session settings per query: {sql!r}")
async def fetch(self, query, *params):
return []
@pytest.fixture
def search_path(monkeypatch):
dialect = FakeDialect()
config = SimpleNamespace(
semantic_min_similarity=0.0,
bm25_min_score=0.0,
text_search_extension="native",
text_search_extension_native_language="english",
)
monkeypatch.setattr(retrieval_mod, "create_sql_dialect", lambda backend: dialect)
monkeypatch.setattr(retrieval_mod, "get_config", lambda: config)
monkeypatch.setattr(retrieval_mod, "fq_table", lambda name: name)
monkeypatch.setattr(retrieval_mod, "get_current_schema", lambda: None)
return dialect
async def _search(conn, limit: int, **kwargs):
await PostgresMemories({}).search(
conn=conn,
bank_id="bank-1",
fact_types=["world", "experience"],
query_embedding="[0.0]",
query_text="alpha beta",
limit=limit,
**kwargs,
)
async def test_arms_ask_for_exactly_the_rows_they_keep(search_path):
"""No row over-fetch: the arms are ordered by distance, so trimming N of 5N in
Python returned precisely what LIMIT N would have the surplus was fetched,
decoded and dropped unread."""
await _search(FakeConn(), BUDGET_MID)
assert search_path.fetch_limits == [BUDGET_MID, BUDGET_MID] # one arm per fact_type
async def test_small_budget_still_covers_the_graph_arms_seeds(search_path):
"""The graph arm reads its entry points from these same rows, so a budget below
GRAPH_SEED_LIMIT must not starve it."""
await _search(FakeConn(), 5, graph_seed_min_similarity=0.3)
assert search_path.fetch_limits == [GRAPH_SEED_LIMIT, GRAPH_SEED_LIMIT]
async def test_no_seed_threshold_means_no_seed_floor(search_path):
"""With the graph arm off, nothing reads past the semantic list itself."""
await _search(FakeConn(), 5)
assert search_path.fetch_limits == [5, 5]
# ---------------------------------------------------------------------------
# The property the change delivers, against a real index
# ---------------------------------------------------------------------------
EMBED_DIM = 384
# Enough to exceed the 200-row candidate list at a budget of 400, and no more: this
# runs alongside timing-sensitive tests, and a bulk load large enough to saturate the
# database starves them.
_ROWS = 600
def _near_query_vector(seed: int) -> str:
"""A distinct vector from one tight cluster.
Clustered rather than uniformly random on purpose: an HNSW graph over scattered
vectors is sparsely connected, so a resumed scan exhausts the reachable set before
it reaches the requested budget and the test measures graph connectivity instead of
the setting under test.
"""
rng = random.Random(seed)
values = [1.0] + [rng.uniform(-0.05, 0.05) for _ in range(EMBED_DIM - 1)]
norm = sum(v * v for v in values) ** 0.5
return "[" + ",".join(f"{v / norm:.5f}" for v in values) + "]"
@pytest.mark.asyncio
async def test_the_kill_switch_flips_real_retrieval_depth(memory, request_context, ann_config):
"""End to end, through the pool: on, the budget reaches the index; off, it does not.
Both halves matter. On is the fix with iterative scans off the ground-layer search
runs once and the scan ends when its ef_search-sized list drains, so the arm cannot
return more than ~200 rows however large the recall budget. Off is the operational
revert, and it has to land on exactly that pre-existing behaviour rather than some
third state nobody runs.
Driven by the environment variable through the pool's own session setup, not by
setting the GUCs by hand, so it covers the path production actually takes. Rows and
index are built directly: the property belongs to the index scan, and going through
retain would drag in extraction and consolidation.
"""
from hindsight_api.engine.search.retrieval import retrieve_semantic_bm25_combined_sql
from hindsight_api.engine.retain.bank_utils import get_or_create_bank_profile
from hindsight_api.engine.task_backend import fq_table
bank_id = f"test_iter_scan_{uuid.uuid4().hex[:8]}"
budget = 400 # deliberately above the standing ef_search of 200
# Creating the bank also builds its per-(bank, fact_type) partial vector index —
# the same one recall uses — so this exercises the production index, not a stand-in.
await get_or_create_bank_profile(memory._backend, bank_id)
pool = await memory._get_pool()
probe = _near_query_vector(0)
table = fq_table("memory_units")
try:
async with pool.acquire() as conn:
await conn.executemany(
f"INSERT INTO {table} (bank_id, text, fact_type, embedding) VALUES ($1, $2, 'world', $3::vector)",
[(bank_id, f"filler fact {i}", _near_query_vector(i)) for i in range(_ROWS)],
)
await conn.execute(f"ANALYZE {table}")
async def semantic_rows(iterative: bool) -> int:
ann_config("ann_iterative_scan", iterative)
# The pool re-applies its session settings on every acquire, so a fresh
# connection resolves the flag again rather than inheriting the value the
# process started with.
async with pool.acquire() as conn:
# The property under test belongs to the ANN scan, not to the planner's
# choice: on a table this size a full scan plus a sort is genuinely
# cheaper, and inflating the fixture until ANN wins would only make the
# test slow. Discourage both alternatives so the ordered path is taken.
await conn.execute("SET enable_seqscan = off")
await conn.execute("SET enable_sort = off")
plan = "\n".join(
r[0]
for r in await conn.fetch(
f"EXPLAIN SELECT id FROM {table} WHERE bank_id = $1 AND fact_type = 'world' "
f"AND embedding IS NOT NULL ORDER BY embedding <=> $2::vector LIMIT {budget}",
bank_id,
probe,
)
)
# "Index Scan" alone is not enough — a btree scan plus a Sort also
# matches, and it returns every row regardless of the candidate list,
# which would make this quietly measure nothing. An ANN scan emits rows
# already ordered, so the giveaway is the absence of a Sort node.
assert "Index Scan" in plan and "Sort" not in plan, f"expected an ANN scan, got:\n{plan}"
result = await retrieve_semantic_bm25_combined_sql(
conn, probe, "", bank_id, ["world"], budget, min_semantic=0.0
)
return len(result["world"].semantic)
with_resume = await semantic_rows(True)
without_resume = await semantic_rows(False)
# On: the budget reaches the index.
assert with_resume == budget, f"expected the full budget, got {with_resume}"
# Off: capped by the candidate list, exactly as before the fix existed.
assert without_resume <= 250, f"expected the scan to stop at ~ef_search, got {without_resume}"
assert without_resume < with_resume
finally:
await memory.delete_bank(bank_id, request_context=request_context)
# ---------------------------------------------------------------------------
# Operational controls
# ---------------------------------------------------------------------------
@pytest.fixture
def ann_config(monkeypatch):
"""Override an ANN config field for one test, without disturbing anything else.
Set on the cached config instance rather than by setting the env var and clearing
the cache: clearing it is process-wide, so every engine built earlier in the
session would silently start resolving a config rebuilt from the current
environment. monkeypatch restores the attribute at teardown.
"""
from hindsight_api.config import _get_raw_config
def _set(field: str, value) -> None:
monkeypatch.setattr(_get_raw_config(), field, value)
return _set
def test_the_kill_switch_removes_the_resume_settings(ann_config):
"""Turning it off must leave a connection exactly as it was before the feature.
Dropping the GUCs rather than sending iterative_scan=off matters for two reasons:
a pgvector older than 0.8 rejects them outright (it reserves the "hnsw." prefix),
and an operator who pinned values server-side keeps them.
"""
ann_config("ann_iterative_scan", False)
settings = ann_search_tuning_settings("pgvector", kind="high_recall")
assert settings == (("hnsw.ef_search", "200"),)
def test_the_scan_ceiling_is_tunable(ann_config):
"""The dial between the previous behaviour and full budget depth."""
ann_config("ann_max_scan_tuples", 1500)
settings = dict(ann_search_tuning_settings("pgvector", kind="high_recall"))
assert settings["hnsw.max_scan_tuples"] == "1500"
assert settings["hnsw.iterative_scan"] == "strict_order"
def test_an_unreadable_ceiling_is_rejected_at_config_load(monkeypatch):
"""Parsing and validation belong to HindsightConfig, not to this module."""
from hindsight_api.config import HindsightConfig
monkeypatch.setenv("HINDSIGHT_API_ANN_MAX_SCAN_TUPLES", "not-a-number")
with pytest.raises(ValueError):
HindsightConfig.from_env()
def test_retain_probing_is_unaffected_by_the_switch(ann_config):
"""Link probing never resumed; the switch has nothing to take from it."""
ann_config("ann_iterative_scan", False)
settings = dict(ann_search_tuning_settings("pgvector", kind="low_latency"))
assert settings == {"hnsw.ef_search": "60"}
@@ -664,6 +664,7 @@ async def test_all_degenerate_facts_still_persist_document_chunks(memory, reques
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_streaming_offsets_chunk_local_causal_fact_indices(memory, request_context, monkeypatch):
"""Causal targets from independently extracted chunks must stay within their source chunk."""
from hindsight_api.engine.response_models import TokenUsage
@@ -765,17 +766,24 @@ async def test_degenerate_fact_preserves_later_chunk_provenance(memory, request_
request_context=request_context,
)
pool = await memory._get_pool()
rows = await pool.fetch(
"""
SELECT units.text AS fact_text, chunks.chunk_index AS chunk_index
FROM memory_units units
JOIN chunks ON chunks.chunk_id = units.chunk_id
WHERE units.bank_id = $1
""",
bank_id,
)
assert {(row["fact_text"], row["chunk_index"]) for row in rows} == {
# Each fact carries the chunk it came from, and the document's chunks carry
# their index, so the join the assertion needs is over two API reads.
chunk_index_by_id = {
chunk["chunk_id"]: chunk["chunk_index"]
for chunk in (
await memory.list_document_chunks(
bank_id, "degen-provenance-document", limit=500, request_context=request_context
)
)["items"]
}
units = (await memory.list_memory_units(bank_id, limit=500, request_context=request_context))["items"]
# Chunkless units (an observation, say) were dropped by the inner join before
# and are dropped here for the same reason: they have no provenance to check.
assert {
(unit["text"], chunk_index_by_id[unit["chunk_id"]])
for unit in units
if unit["chunk_id"] in chunk_index_by_id
} == {
("chunk zero real fact", 0),
("chunk one real fact", 1),
}
@@ -22,8 +22,8 @@ async def _retain(memory, bank_id, document_id, content, request_context):
async def _bank_entry(memory, bank_id, request_context):
banks = await memory.list_banks(request_context=request_context)
return next(b for b in banks if b["bank_id"] == bank_id)
page = await memory.list_banks(search_query=bank_id, request_context=request_context)
return next(b for b in page["banks"] if b["bank_id"] == bank_id)
def _ts(value: str | None) -> datetime:
@@ -84,8 +84,8 @@ async def test_banks_are_ordered_by_last_write(memory, request_context):
# the most recently written bank.
await _retain(memory, older_bank, "doc-a", "xyzabc123 !@# alpha revised", request_context)
banks = await memory.list_banks(request_context=request_context)
ordered = [b["bank_id"] for b in banks if b["bank_id"] in (older_bank, newer_bank)]
page = await memory.list_banks(search_query="test_last_write_order_", request_context=request_context)
ordered = [b["bank_id"] for b in page["banks"] if b["bank_id"] in (older_bank, newer_bank)]
assert ordered == [older_bank, newer_bank]
finally:
@@ -47,6 +47,7 @@ async def _insert_memory(memory, bank_id: str, text: str, *, failed: bool = Fals
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_bank_stats_exposes_memory_write_watermark(api_client, memory, test_bank_id):
"""/stats must carry the bank's newest memory write time.
@@ -217,6 +218,7 @@ async def test_memories_timeseries_reflects_retained_memories(api_client, test_b
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_bank_stats_reports_failed_consolidation(api_client, memory, test_bank_id):
"""/stats must surface the count of memories with consolidation_failed_at set."""
try:
@@ -237,6 +239,7 @@ async def test_bank_stats_reports_failed_consolidation(api_client, memory, test_
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_pending_consolidation_matches_pending_memory_list(api_client, memory, test_bank_id):
"""pending_consolidation must agree with ?consolidation_state=pending.
@@ -266,6 +269,7 @@ async def test_pending_consolidation_matches_pending_memory_list(api_client, mem
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_list_memories_filter_by_consolidation_state_failed(api_client, memory, test_bank_id):
"""?consolidation_state=failed returns only memories with consolidation_failed_at set."""
try:
@@ -332,6 +336,7 @@ async def test_bank_stats_link_counts_have_no_join(api_client, test_bank_id):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_get_bank_freshness_returns_only_consolidation_fields(memory, test_bank_id):
"""get_bank_freshness must return just the freshness keys, no link aggregation."""
from hindsight_api.extensions import RequestContext
@@ -55,6 +55,7 @@ class TestDistributedBankStatsCache:
assert isinstance(memory._bank_stats_cache, DistributedBankStatsCache)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_result_is_written_and_served_from_table(self, memory: MemoryEngine, request_context: RequestContext):
if memory._database_backend_type != "postgresql":
pytest.skip("distributed cache is PostgreSQL-only")
@@ -93,6 +94,7 @@ class TestDistributedBankStatsCache:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_force_refresh_bypasses_and_updates_cache(
self, memory: MemoryEngine, request_context: RequestContext
):
@@ -127,6 +129,7 @@ class TestDistributedBankStatsCache:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_degrades_when_cache_table_unreachable(self, memory: MemoryEngine, request_context: RequestContext):
if memory._database_backend_type != "postgresql":
pytest.skip("distributed cache is PostgreSQL-only")
+25 -30
View File
@@ -7,21 +7,8 @@ from asyncpg.exceptions import DeadlockDetectedError
from hindsight_api.engine.retain import bank_utils
class _FailingIndexOps:
async def create_bank_vector_indexes(self, *args, **kwargs) -> None:
raise RuntimeError("simulated per-bank vector index DDL failure")
class _DeadlockOnceIndexOps:
"""Raises a deadlock on the first per-bank index DDL, then succeeds."""
def __init__(self) -> None:
self.calls = 0
async def create_bank_vector_indexes(self, *args, **kwargs) -> None:
self.calls += 1
if self.calls == 1:
raise DeadlockDetectedError("deadlock detected")
class _BankOps:
"""Dialect ops stub. Bank creation issues no index DDL (#3485), so this is bare."""
class _FakeTransaction:
@@ -39,10 +26,13 @@ class _FakeTransaction:
class _FakeConnection:
def __init__(self) -> None:
def __init__(self, raise_on_insert: BaseException | None = None) -> None:
self.committed_bank: str | None = None
self.pending_bank: str | None = None
self.in_transaction = False
self.insert_calls = 0
# Raised by the first INSERT only, then cleared, so a retry succeeds.
self._raise_on_insert = raise_on_insert
def transaction(self) -> _FakeTransaction:
return _FakeTransaction(self)
@@ -58,6 +48,10 @@ class _FakeConnection:
}
async def fetchval(self, query: str, bank_id: str, *args):
self.insert_calls += 1
if self._raise_on_insert is not None:
error, self._raise_on_insert = self._raise_on_insert, None
raise error
if self.in_transaction:
self.pending_bank = bank_id
else:
@@ -68,13 +62,13 @@ class _FakeConnection:
class _FakePool:
def __init__(self, conn: _FakeConnection, ops=None) -> None:
self.conn = conn
self.ops = ops if ops is not None else _FailingIndexOps()
self.ops = ops if ops is not None else _BankOps()
@pytest.mark.asyncio
async def test_lazy_bank_create_rolls_back_on_vector_index_failure(monkeypatch: pytest.MonkeyPatch) -> None:
"""A failed per-bank index DDL must not leave an orphaned bank row."""
conn = _FakeConnection()
async def test_lazy_bank_create_rolls_back_on_failure(monkeypatch: pytest.MonkeyPatch) -> None:
"""A failure inside the bank-create transaction must not leave an orphaned bank row."""
conn = _FakeConnection(raise_on_insert=RuntimeError("simulated bank insert failure"))
pool = _FakePool(conn)
@asynccontextmanager
@@ -83,24 +77,25 @@ async def test_lazy_bank_create_rolls_back_on_vector_index_failure(monkeypatch:
monkeypatch.setattr(bank_utils, "acquire_with_retry", acquire_without_transaction)
with pytest.raises(RuntimeError, match="simulated per-bank vector index DDL failure"):
with pytest.raises(RuntimeError, match="simulated bank insert failure"):
await bank_utils.get_or_create_bank_profile(pool, "atomicity-test-bank")
profile = await bank_utils.get_bank_profile_if_exists(pool, "atomicity-test-bank")
assert profile is None, "bank row should roll back when per-bank vector index creation fails"
assert profile is None, "bank row should roll back when the create transaction fails"
@pytest.mark.asyncio
async def test_get_or_create_bank_profile_retries_on_deadlock(monkeypatch: pytest.MonkeyPatch) -> None:
"""A deadlock during per-bank index DDL is retried on a fresh transaction.
"""A deadlock inside the create transaction is retried on a fresh one.
Concurrent bank creation issues CREATE INDEX against the shared memory_units
table, which can deadlock (asyncpg DeadlockDetectedError). The pool-owning
get_or_create_bank_profile must retry rather than surface the deadlock.
The retry originally guarded the per-bank CREATE INDEX that ran inline here;
that DDL is gone (#3485), but the lazy create can still lose a deadlock to a
concurrent writer touching the same bank row, and the body is idempotent
(INSERT ... ON CONFLICT DO NOTHING), so it must still retry rather than
surface the deadlock to the caller.
"""
conn = _FakeConnection()
ops = _DeadlockOnceIndexOps()
pool = _FakePool(conn, ops=ops)
conn = _FakeConnection(raise_on_insert=DeadlockDetectedError("deadlock detected"))
pool = _FakePool(conn)
@asynccontextmanager
async def acquire(*args, **kwargs):
@@ -117,6 +112,6 @@ async def test_get_or_create_bank_profile_retries_on_deadlock(monkeypatch: pytes
result = await bank_utils.get_or_create_bank_profile(pool, "deadlock-retry-bank")
assert result.created is True
assert ops.calls == 2, "index DDL should be attempted twice (deadlock, then success)"
assert conn.insert_calls == 2, "the insert should be attempted twice (deadlock, then success)"
profile = await bank_utils.get_bank_profile_if_exists(pool, "deadlock-retry-bank")
assert profile is not None, "bank must exist after the retry succeeds"
@@ -1,4 +1,4 @@
"""Per-bank vector-index DDL is serialized per table within a process.
"""Per-bank vector-index drop DDL is serialized per table within a process.
Concurrent index DDL on the shared ``memory_units`` table deadlocks by design:
DROP INDEX CONCURRENTLY holds ShareUpdateExclusive while waiting out every
@@ -10,11 +10,15 @@ test-api 3/3). Advisory locks are banned in this codebase (poolers), so the
fix is an in-process asyncio lock on ``PostgreSQLOps`` plus a much larger
jittered retry budget for the cross-process residue.
Bank deletion is now the only request path that issues vector-index DDL
indexes are earned by size and built by the maintenance sweep (#3485) — but the
teardown storm this guards against is unchanged, because a bank large enough to
have indexes still drops three of them when it goes.
Two layers are proven here:
* unit (fake conn): create and drop DDL for one table never interleave, the
create and drop sides contend on the same lock, and the lock is released
when a statement raises;
* unit (fake conn): concurrent drops for one table never interleave, different
tables do not contend, and the lock is released when a statement raises;
* integration: a many-bank concurrent ``delete_bank`` storm the CI failure
shape completes without ``DeadlockDetectedError``.
"""
@@ -25,8 +29,14 @@ import uuid
import pytest
from hindsight_api.engine.db.ops_postgresql import PostgreSQLOps
from hindsight_api.engine.db_utils import retry_with_backoff
from hindsight_api.engine.retain.bank_utils import _BANK_INDEX_FACT_TYPES, _bank_index_name, _vector_index_clause
# Shares the reconcile suite's xdist worker: both do heavy CREATE/DROP INDEX
# CONCURRENTLY against the single shared public.memory_units, and concurrent
# index DDL on one relation deadlocks by design.
pytestmark = pytest.mark.xdist_group("vector_index_reconcile")
_SCHEMA = "public"
_INDEX_CLAUSE = "USING hnsw (embedding vector_cosine_ops)"
@@ -57,7 +67,7 @@ class _OverlapTrackingConn:
return "OK"
async def test_concurrent_create_and_drop_on_one_table_serialize():
async def test_concurrent_drops_on_one_table_serialize():
ops = PostgreSQLOps()
conn = _OverlapTrackingConn()
table = f"{_SCHEMA}.memory_units"
@@ -65,10 +75,9 @@ async def test_concurrent_create_and_drop_on_one_table_serialize():
await asyncio.gather(
ops.drop_bank_vector_indexes(conn, _SCHEMA, uuid.uuid4().hex, _BANK_INDEX_FACT_TYPES),
ops.drop_bank_vector_indexes(conn, _SCHEMA, uuid.uuid4().hex, _BANK_INDEX_FACT_TYPES),
# The drop side reconstructs the create side's fq-table key from
# `schema`, so a create must queue behind the drops too.
ops.create_bank_vector_indexes(conn, table, "bank-1", uuid.uuid4().hex, _INDEX_CLAUSE, _BANK_INDEX_FACT_TYPES),
ops.drop_bank_vector_indexes(conn, _SCHEMA, uuid.uuid4().hex, _BANK_INDEX_FACT_TYPES),
)
assert ops._index_ddl_lock(table) is ops._index_ddl_lock(f"{_SCHEMA}.memory_units")
assert len(conn.calls) == 3 * len(_BANK_INDEX_FACT_TYPES)
assert conn.max_in_flight == 1, "vector-index DDL statements overlapped despite the per-table lock"
@@ -86,11 +95,10 @@ async def test_lock_released_when_ddl_raises():
with pytest.raises(RuntimeError):
await ops.drop_bank_vector_indexes(conn, _SCHEMA, uuid.uuid4().hex, _BANK_INDEX_FACT_TYPES)
# A subsequent create must not hang on a lock the failed drop never released.
# A subsequent drop must not hang on a lock the failed one never released.
ok = _OverlapTrackingConn()
await asyncio.wait_for(
ops.create_bank_vector_indexes(
conn, f"{_SCHEMA}.memory_units", "bank-1", uuid.uuid4().hex, _INDEX_CLAUSE, _BANK_INDEX_FACT_TYPES
),
ops.drop_bank_vector_indexes(ok, _SCHEMA, uuid.uuid4().hex, _BANK_INDEX_FACT_TYPES),
timeout=2.0,
)
@@ -115,6 +123,25 @@ async def test_concurrent_bank_delete_storm_does_not_deadlock(memory, request_co
bank_id: str(await conn.fetchval("SELECT internal_id FROM banks WHERE bank_id = $1", bank_id))
for bank_id in bank_ids
}
# Bank creation no longer builds these, so the storm has to be staged:
# give every bank its three indexes up front, as a bank past the size
# threshold would have.
for bank_id, internal_id in internal_ids.items():
literal = await conn.fetchval("SELECT quote_literal($1::text)", bank_id)
for ft in _BANK_INDEX_FACT_TYPES:
name = _bank_index_name(ft, internal_id)
# CONCURRENTLY, and retried: a plain CREATE INDEX takes ShareLock
# on the shared memory_units table, which closes a deadlock cycle
# with another xdist worker's DROP INDEX CONCURRENTLY
# (ShareUpdateExclusive). Staging the storm must not itself be the
# storm.
await retry_with_backoff(
lambda name=name, ft=ft: conn.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {name} "
f"ON {_SCHEMA}.memory_units {_INDEX_CLAUSE} "
f"WHERE fact_type = '{ft}' AND bank_id = {literal}"
)
)
await asyncio.gather(*(memory.delete_bank(bank_id, request_context=request_context) for bank_id in bank_ids))
@@ -43,6 +43,7 @@ async def _seed_bank_and_document(conn, bank_id: str, document_id: str) -> None:
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_store_chunks_batch_is_idempotent_for_same_chunk_id(memory):
"""
Regression for #977.
+245 -361
View File
@@ -28,6 +28,20 @@ from hindsight_api.engine.reflect.tools import (
from tests.llm_judge import assert_meets_criteria
async def _unconsolidated(memory: MemoryEngine, bank_id: str, request_context) -> int:
"""How many source facts are still waiting to be consolidated.
consolidation_state='pending' is the read API's name for the predicate these
tests used to spell out in SQL: consolidated_at IS NULL and a source fact type.
It additionally excludes facts whose consolidation permanently failed the
stricter reading, and the one the assertions here actually mean.
"""
page = await memory.list_memory_units(
bank_id=bank_id, consolidation_state="pending", limit=1, request_context=request_context
)
return page["total"]
@pytest.fixture(autouse=True)
def enable_observations():
"""Enable observations for all tests in this module."""
@@ -63,22 +77,18 @@ class TestConsolidationIntegration:
request_context=request_context,
)
# Verify observation exists in memory_units
# Verify observation exists via the list API
# (consolidation already ran as part of retain via SyncTaskBackend)
async with memory._pool.acquire() as conn:
observations = await conn.fetch(
"""
SELECT id, text, proof_count, fact_type
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
""",
bank_id,
observations = (
await memory.list_memory_units(
bank_id, fact_type="observation", limit=1000, request_context=request_context
)
# With the deterministic mock, consolidation always produces observations
assert len(observations) >= 1, "Consolidation must create at least one observation"
obs = observations[0]
assert obs["proof_count"] >= 1
assert obs["fact_type"] == "observation"
)["items"]
# With the deterministic mock, consolidation always produces observations
assert len(observations) >= 1, "Consolidation must create at least one observation"
obs = observations[0]
assert obs["proof_count"] >= 1
assert obs["fact_type"] == "observation"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@@ -106,21 +116,16 @@ class TestConsolidationIntegration:
)
# Check observations after both retains
async with memory._pool.acquire() as conn:
observations = await conn.fetch(
"""
SELECT id, text, proof_count
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
ORDER BY proof_count DESC
""",
bank_id,
observations = (
await memory.list_memory_units(
bank_id, fact_type="observation", limit=1000, request_context=request_context
)
)["items"]
# Must have at least one observation from consolidation
assert len(observations) >= 1, "Consolidation must create observations from retained memories"
assert all(obs["text"] for obs in observations)
assert all(obs["proof_count"] >= 1 for obs in observations)
# Must have at least one observation from consolidation
assert len(observations) >= 1, "Consolidation must create observations from retained memories"
assert all(obs["text"] for obs in observations)
assert all(obs["proof_count"] >= 1 for obs in observations)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@@ -208,31 +213,17 @@ class TestConsolidationIntegration:
)
# Check observation and its entity links
async with memory._pool.acquire() as conn:
observation = await conn.fetchrow(
"""
SELECT id
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
LIMIT 1
""",
bank_id,
)
observations = await memory.list_memory_units(
bank_id, fact_type="observation", limit=1, request_context=request_context
)
# Consolidation must create an observation
assert observation is not None, "Consolidation must create an observation"
# Consolidation must create an observation
assert observations["items"], "Consolidation must create an observation"
# Check if entity links were copied
entity_links = await conn.fetch(
"""
SELECT entity_id
FROM unit_entities
WHERE unit_id = $1
""",
observation["id"],
)
# Observation should have inherited entity links from source memory
assert entity_links is not None
# An observation carries the entities of the facts it was drawn from, so the
# detail view is where the copied links show up.
observation = await memory.get_memory_unit(bank_id, observations["items"][0]["id"], request_context)
assert observation["entities"] is not None
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@@ -267,6 +258,7 @@ class TestConsolidationIntegration:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_consolidation_uses_source_memory_ids(self, memory: MemoryEngine, request_context):
"""Test that observations use source_memory_ids (not memory_links) to track source facts.
@@ -366,31 +358,25 @@ class TestConsolidationIntegration:
)
# Check observations - should have separate observations for each person
async with memory._pool.acquire() as conn:
observations = await conn.fetch(
"""
SELECT id, text, source_memory_ids
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
""",
bank_id,
observations = (
await memory.list_memory_units(
bank_id, fact_type="observation", limit=1000, request_context=request_context
)
)["items"]
# Should have multiple observations (one per person/fact)
# Not everything merged into one
assert len(observations) >= 2, (
f"Expected multiple observations for different people, got {len(observations)}"
)
# Should have multiple observations (one per person/fact)
# Not everything merged into one
assert len(observations) >= 2, f"Expected multiple observations for different people, got {len(observations)}"
# Fast structural check first: no single observation should name more
# than one of {John, Mary, Bob}. This catches the obvious failure mode
# cheaply without paying for a judge call per observation.
for obs in observations:
text = obs["text"].lower()
people_mentioned = sum(1 for name in ["john", "mary", "bob"] if name in text)
assert people_mentioned <= 1, f"Observation should not merge different people: {obs['text']}"
# Fast structural check first: no single observation should name more
# than one of {John, Mary, Bob}. This catches the obvious failure mode
# cheaply without paying for a judge call per observation.
for obs in observations:
text = obs["text"].lower()
people_mentioned = sum(1 for name in ["john", "mary", "bob"] if name in text)
assert people_mentioned <= 1, f"Observation should not merge different people: {obs['text']}"
obs_listing = "\n".join(f"Observation {i + 1}: {obs['text']}" for i, obs in enumerate(observations))
obs_listing = "\n".join(f"Observation {i + 1}: {obs['text']}" for i, obs in enumerate(observations))
# Semantic backup: catch the case where the LLM merges facts about different
# people using pronouns or referent shifts that bypass the proper-noun check
@@ -443,15 +429,12 @@ class TestConsolidationIntegration:
await memory.wait_for_background_tasks()
# Check we have one observation
async with memory._pool.acquire() as conn:
obs_before = await conn.fetch(
"""
SELECT id, text FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
""",
bank_id,
obs_before = (
await memory.list_memory_units(
bank_id, fact_type="observation", limit=1000, request_context=request_context
)
count_before = len(obs_before)
)["items"]
count_before = len(obs_before)
# Add contradicting fact (same person, same topic, opposite sentiment)
await memory.retain_async(
@@ -462,29 +445,23 @@ class TestConsolidationIntegration:
await memory.wait_for_background_tasks()
# Check observations after consolidation
async with memory._pool.acquire() as conn:
observations = await conn.fetch(
"""
SELECT id, text, source_memory_ids
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
""",
bank_id,
)
observations = (
await memory.list_memory_units(bank_id, fact_type="observation", limit=500, request_context=request_context)
)["items"]
# The contradiction should be reflected in observations — either:
# 1. Merged into one observation with temporal context (e.g., "used to love, now hates")
# 2. The original observation updated to reflect the new state
# 3. Two separate observations capturing each state
# The key is that the contradiction is tracked, not ignored.
assert len(observations) >= 1, "Should have at least one observation after contradiction"
# The contradiction should be reflected in observations — either:
# 1. Merged into one observation with temporal context (e.g., "used to love, now hates")
# 2. The original observation updated to reflect the new state
# 3. Two separate observations capturing each state
# The key is that the contradiction is tracked, not ignored.
assert len(observations) >= 1, "Should have at least one observation after contradiction"
# Format as numbered list rather than pipe-separated — weaker judge
# models read pipe-joins as a single conflated statement.
obs_listing = "\n".join(f"Observation {i + 1}: {obs['text']}" for i, obs in enumerate(observations))
all_source_ids = []
for obs in observations:
all_source_ids.extend(obs["source_memory_ids"] or [])
# Format as numbered list rather than pipe-separated — weaker judge
# models read pipe-joins as a single conflated statement.
obs_listing = "\n".join(f"Observation {i + 1}: {obs['text']}" for i, obs in enumerate(observations))
all_source_ids = []
for obs in observations:
all_source_ids.extend(obs["source_memory_ids"])
# Either the observations reference both sentiments (via text content) or the
# consolidation linked both source memories together. The judge evaluates the
@@ -543,11 +520,11 @@ class TestConsolidationIntegration:
await memory.wait_for_background_tasks()
async with memory._pool.acquire() as conn:
observations = await conn.fetch(
"SELECT id, text FROM memory_units WHERE bank_id = $1 AND fact_type = 'observation' ORDER BY created_at",
bank_id,
observations = (
await memory.list_memory_units(
bank_id, fact_type="observation", limit=1000, request_context=request_context
)
)["items"]
obs_count = len(observations)
obs_texts = [o["text"] for o in observations]
@@ -760,19 +737,16 @@ class TestConsolidationTagRouting:
await self._retain_with_tags(memory, bank_id, "Alice likes coffee.", ["alice"], request_context)
# Check observation has correct tags
async with memory._pool.acquire() as conn:
obs_before = await conn.fetch(
"""
SELECT id, text, tags FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
""",
bank_id,
obs_before = (
await memory.list_memory_units(
bank_id, fact_type="observation", limit=1000, request_context=request_context
)
)["items"]
count_before = len(obs_before)
if obs_before:
assert "alice" in (obs_before[0]["tags"] or []), (
f"Expected observation to have 'alice' tag, got: {obs_before[0]['tags']}"
)
count_before = len(obs_before)
if obs_before:
assert "alice" in (obs_before[0]["tags"] or []), (
f"Expected observation to have 'alice' tag, got: {obs_before[0]['tags']}"
)
# Retain related memory with same tags
await self._retain_with_tags(
@@ -780,25 +754,22 @@ class TestConsolidationTagRouting:
)
# Check observations - should NOT have increased (same scope update)
async with memory._pool.acquire() as conn:
obs_after = await conn.fetch(
"""
SELECT id, text, tags, source_memory_ids FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
""",
bank_id,
obs_after = (
await memory.list_memory_units(
bank_id, fact_type="observation", limit=1000, request_context=request_context
)
)["items"]
# Count of observations should stay same or decrease (merge)
assert len(obs_after) <= count_before + 1, (
f"Same scope fact should update existing observation, not create new. "
f"Before: {count_before}, After: {len(obs_after)}"
)
# Count of observations should stay same or decrease (merge)
assert len(obs_after) <= count_before + 1, (
f"Same scope fact should update existing observation, not create new. "
f"Before: {count_before}, After: {len(obs_after)}"
)
# The observation(s) should still have alice tag
for obs in obs_after:
if "coffee" in obs["text"].lower() or "espresso" in obs["text"].lower():
assert "alice" in (obs["tags"] or []), f"Updated observation should keep 'alice' tag: {obs['text']}"
# The observation(s) should still have alice tag
for obs in obs_after:
if "coffee" in obs["text"].lower() or "espresso" in obs["text"].lower():
assert "alice" in (obs["tags"] or []), f"Updated observation should keep 'alice' tag: {obs['text']}"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@@ -828,48 +799,41 @@ class TestConsolidationTagRouting:
await memory.wait_for_background_tasks()
# Check untagged observation exists
async with memory._pool.acquire() as conn:
obs_before = await conn.fetch(
"""
SELECT id, text, tags FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
""",
bank_id,
obs_before = (
await memory.list_memory_units(
bank_id, fact_type="observation", limit=1000, request_context=request_context
)
)["items"]
count_before = len(obs_before)
# Should be untagged or have empty tags
if obs_before:
assert not obs_before[0]["tags"] or len(obs_before[0]["tags"]) == 0, (
f"Expected untagged observation, got: {obs_before[0]['tags']}"
)
count_before = len(obs_before)
# Should be untagged or have empty tags
if obs_before:
assert not obs_before[0]["tags"] or len(obs_before[0]["tags"]) == 0, (
f"Expected untagged observation, got: {obs_before[0]['tags']}"
)
# Retain scoped memory that relates to the global topic
await self._retain_with_tags(memory, bank_id, "Pizza originated in Naples.", ["history"], request_context)
await memory.wait_for_background_tasks()
# Check - global observation should be updated OR new scoped observation created
async with memory._pool.acquire() as conn:
obs_after = await conn.fetch(
"""
SELECT id, text, tags, source_memory_ids FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
ORDER BY created_at
""",
bank_id,
obs_after = (
await memory.list_memory_units(
bank_id, fact_type="observation", limit=1000, request_context=request_context
)
)["items"]
# At least one observation should exist
assert len(obs_after) >= 1, "Expected at least one observation"
# At least one observation should exist
assert len(obs_after) >= 1, "Expected at least one observation"
# Check that global observation was updated (source_memory_ids increased)
# OR new observation was created with appropriate tags
global_observations = [o for o in obs_after if not o["tags"] or len(o["tags"]) == 0]
scoped_observations = [o for o in obs_after if o["tags"] and len(o["tags"]) > 0]
# Check that global observation was updated (source_memory_ids increased)
# OR new observation was created with appropriate tags
global_observations = [o for o in obs_after if not o["tags"] or len(o["tags"]) == 0]
scoped_observations = [o for o in obs_after if o["tags"] and len(o["tags"]) > 0]
# Either global was updated or scoped was created
assert len(global_observations) >= 1 or len(scoped_observations) >= 1, (
"Expected either global observation update or scoped observation creation"
)
# Either global was updated or scoped was created
assert len(global_observations) >= 1 or len(scoped_observations) >= 1, (
"Expected either global observation update or scoped observation creation"
)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@@ -897,15 +861,12 @@ class TestConsolidationTagRouting:
await memory.wait_for_background_tasks()
# Check Alice's observation exists with correct tags
async with memory._pool.acquire() as conn:
obs_alice = await conn.fetch(
"""
SELECT id, text, tags FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
""",
bank_id,
obs_alice = (
await memory.list_memory_units(
bank_id, fact_type="observation", limit=1000, request_context=request_context
)
count_before = len(obs_alice)
)["items"]
count_before = len(obs_alice)
# Retain Bob's memory that relates to Alice's topic (cross-scope)
await self._retain_with_tags(
@@ -914,28 +875,22 @@ class TestConsolidationTagRouting:
await memory.wait_for_background_tasks()
# Check observations
async with memory._pool.acquire() as conn:
obs_after = await conn.fetch(
"""
SELECT id, text, tags, source_memory_ids FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
ORDER BY created_at
""",
bank_id,
obs_after = (
await memory.list_memory_units(
bank_id, fact_type="observation", limit=1000, request_context=request_context
)
)["items"]
# Note: some LLMs may or may not consolidate cross-scope facts.
# Just verify structural correctness of any observations that exist.
# Note: some LLMs may or may not consolidate cross-scope facts.
# Just verify structural correctness of any observations that exist.
# If observations were created, ensure alice and bob are not merged into same observation
# (cross-scope merging should not produce an observation with both tags)
if obs_after:
observations_with_both = [
o for o in obs_after if o["tags"] and "alice" in o["tags"] and "bob" in o["tags"]
]
assert len(observations_with_both) == 0, (
"Should not merge different scopes into one observation with both tags"
)
# If observations were created, ensure alice and bob are not merged into same observation
# (cross-scope merging should not produce an observation with both tags)
if obs_after:
observations_with_both = [o for o in obs_after if o["tags"] and "alice" in o["tags"] and "bob" in o["tags"]]
assert len(observations_with_both) == 0, (
"Should not merge different scopes into one observation with both tags"
)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@@ -962,21 +917,18 @@ class TestConsolidationTagRouting:
)
# Check observation was created with correct tags
async with memory._pool.acquire() as conn:
observations = await conn.fetch(
"""
SELECT id, text, tags FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
""",
bank_id,
observations = (
await memory.list_memory_units(
bank_id, fact_type="observation", limit=1000, request_context=request_context
)
)["items"]
assert len(observations) >= 1, "Expected observation to be created"
assert len(observations) >= 1, "Expected observation to be created"
# The observation should have the fact's tags
obs = observations[0]
assert obs["tags"] is not None, "Observation should have tags"
assert "project_x" in obs["tags"], f"Observation should have 'project_x' tag, got: {obs['tags']}"
# The observation should have the fact's tags
obs = observations[0]
assert obs["tags"] is not None, "Observation should have tags"
assert "project_x" in obs["tags"], f"Observation should have 'project_x' tag, got: {obs['tags']}"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@@ -1013,22 +965,18 @@ class TestConsolidationTagRouting:
await memory.wait_for_background_tasks()
# Check observations
async with memory._pool.acquire() as conn:
observations = await conn.fetch(
"""
SELECT id, text, tags, source_memory_ids FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
ORDER BY created_at
""",
bank_id,
observations = (
await memory.list_memory_units(
bank_id, fact_type="observation", limit=1000, request_context=request_context
)
)["items"]
# Either alice's observation was updated OR a global observation was created
# This is valid LLM behavior - just verify no errors and structure is correct.
# Note: with some LLMs, a single simple fact may not generate an observation,
# so we don't assert a minimum count - just verify structural correctness if any exist.
for obs in observations:
assert obs["text"], "Observation should have text"
# Either alice's observation was updated OR a global observation was created
# This is valid LLM behavior - just verify no errors and structure is correct.
# Note: with some LLMs, a single simple fact may not generate an observation,
# so we don't assert a minimum count - just verify structural correctness if any exist.
for obs in observations:
assert obs["text"], "Observation should have text"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@@ -1099,15 +1047,12 @@ class TestConsolidationTagRouting:
await self._retain_with_tags(memory, bank_id, "Alice drinks coffee every morning.", ["alice"], request_context)
# Check observations before
async with memory._pool.acquire() as conn:
obs_before = await conn.fetch(
"""
SELECT id, text, tags, source_memory_ids FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
""",
bank_id,
obs_before = (
await memory.list_memory_units(
bank_id, fact_type="observation", limit=1000, request_context=request_context
)
count_before = len(obs_before)
)["items"]
count_before = len(obs_before)
# Add fact that could relate to both
await self._retain_with_tags(
@@ -1115,24 +1060,20 @@ class TestConsolidationTagRouting:
)
# Check observations after
async with memory._pool.acquire() as conn:
obs_after = await conn.fetch(
"""
SELECT id, text, tags, source_memory_ids, proof_count FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
ORDER BY created_at
""",
bank_id,
obs_after = (
await memory.list_memory_units(
bank_id, fact_type="observation", limit=1000, request_context=request_context
)
)["items"]
# Should have processed without errors
assert len(obs_after) >= 1, "Expected at least one observation"
# Should have processed without errors
assert len(obs_after) >= 1, "Expected at least one observation"
# Check that consolidation worked (either updates or maintains structure)
# The key is no errors and proper tag handling
for obs in obs_after:
assert obs["text"], "Observation should have text"
# Tags should be consistent (not mixing alice and bob, etc.)
# Check that consolidation worked (either updates or maintains structure)
# The key is no errors and proper tag handling
for obs in obs_after:
assert obs["text"], "Observation should have text"
# Tags should be consistent (not mixing alice and bob, etc.)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@@ -1442,34 +1383,29 @@ class TestObservationDrillDown:
)
# Get the observation with source_memory_ids
async with memory._pool.acquire() as conn:
obs_rows = await conn.fetch(
"""
SELECT id, text, proof_count, source_memory_ids
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
""",
bank_id,
)
obs_rows = (
await memory.list_memory_units(bank_id, fact_type="observation", limit=500, request_context=request_context)
)["items"]
assert obs_rows, "Consolidation must create observations"
# Collect all source_memory_ids across all observations
all_source_ids = []
for obs in obs_rows:
all_source_ids.extend(obs["source_memory_ids"] or [])
all_source_ids.extend(obs["source_memory_ids"])
assert all_source_ids, "Observations must have source_memory_ids"
# Verify source_memory_ids point to actual memories
async with memory._pool.acquire() as conn:
source_memories = await conn.fetch(
"""
SELECT id, text FROM memory_units
WHERE id = ANY($1) AND fact_type IN ('world', 'experience')
""",
all_source_ids,
# Verify source_memory_ids point to actual memories. The list arm takes both
# source fact types at once, so the lineage is checked against the same set
# the SQL IN clause used to name.
source_facts = (
await memory.list_memory_units(
bank_id, fact_type=["world", "experience"], limit=500, request_context=request_context
)
)["items"]
wanted = set(all_source_ids)
source_memories = [m for m in source_facts if m["id"] in wanted]
assert len(source_memories) >= 1, (
f"source_memory_ids should point to valid memories. IDs: {all_source_ids}, Found: {len(source_memories)}"
@@ -1519,14 +1455,11 @@ class TestHierarchicalRetrieval:
)
# Verify observation was created
async with memory._pool.acquire() as conn:
obs_count = await conn.fetchval(
"""
SELECT COUNT(*) FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
""",
bank_id,
obs_count = (
await memory.list_memory_units(
bank_id, fact_type="observation", limit=1000, request_context=request_context
)
)["total"]
assert obs_count >= 1, "Consolidation should have created an observation"
# Create a mental model about John (higher quality, user-curated)
@@ -1992,11 +1925,11 @@ async def test_consolidation_with_observations_mission(memory: "MemoryEngine", r
content="Alice uses Python for data analysis and loves its simplicity.",
request_context=request_context,
)
async with memory._pool.acquire() as conn:
observations = await conn.fetch(
"SELECT id, text, fact_type FROM memory_units WHERE bank_id = $1 AND fact_type = 'observation'",
bank_id,
observations = (
await memory.list_memory_units(
bank_id, fact_type="observation", limit=1000, request_context=request_context
)
)["items"]
assert isinstance(observations, list)
finally:
memory._config_resolver._global_config = original_global_config
@@ -2010,6 +1943,7 @@ async def test_consolidation_with_observations_mission(memory: "MemoryEngine", r
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_observation_scopes_explicit_multi_pass(memory: MemoryEngine, request_context):
"""Test that observation_scopes with an explicit list triggers separate consolidation passes.
@@ -2037,16 +1971,9 @@ async def test_observation_scopes_explicit_multi_pass(memory: MemoryEngine, requ
request_context=request_context,
)
async with memory._pool.acquire() as conn:
observations = await conn.fetch(
"""
SELECT id, text, tags
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
ORDER BY created_at
""",
bank_id,
)
observations = (
await memory.list_memory_units(bank_id, fact_type="observation", limit=1000, request_context=request_context)
)["items"]
try:
# Must have at least 2 observations (one per tag scope)
@@ -2073,6 +2000,7 @@ async def test_observation_scopes_explicit_multi_pass(memory: MemoryEngine, requ
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_observation_scopes_per_tag(memory: MemoryEngine, request_context):
"""Test that observation_scopes='per_tag' derives one pass per individual tag.
@@ -2095,16 +2023,9 @@ async def test_observation_scopes_per_tag(memory: MemoryEngine, request_context)
request_context=request_context,
)
async with memory._pool.acquire() as conn:
observations = await conn.fetch(
"""
SELECT id, text, tags
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
ORDER BY created_at
""",
bank_id,
)
observations = (
await memory.list_memory_units(bank_id, fact_type="observation", limit=1000, request_context=request_context)
)["items"]
try:
assert len(observations) >= 2, (
@@ -2147,16 +2068,9 @@ async def test_observation_scopes_combined(memory: MemoryEngine, request_context
request_context=request_context,
)
async with memory._pool.acquire() as conn:
observations = await conn.fetch(
"""
SELECT id, text, tags
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
ORDER BY created_at
""",
bank_id,
)
observations = (
await memory.list_memory_units(bank_id, fact_type="observation", limit=1000, request_context=request_context)
)["items"]
try:
assert len(observations) >= 1, "Expected at least 1 observation, got 0"
@@ -2178,6 +2092,7 @@ async def test_observation_scopes_combined(memory: MemoryEngine, request_context
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_observation_scopes_all_combinations(memory: MemoryEngine, request_context):
"""Test that observation_scopes='all_combinations' generates passes for every tag subset.
@@ -2201,16 +2116,9 @@ async def test_observation_scopes_all_combinations(memory: MemoryEngine, request
request_context=request_context,
)
async with memory._pool.acquire() as conn:
observations = await conn.fetch(
"""
SELECT id, text, tags
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
ORDER BY created_at
""",
bank_id,
)
observations = (
await memory.list_memory_units(bank_id, fact_type="observation", limit=1000, request_context=request_context)
)["items"]
try:
# With 2 tags there are 3 subsets: {alice}, {ben}, {alice, ben}
@@ -2671,6 +2579,7 @@ def test_max_observations_per_scope_default():
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_count_observations_for_scope(memory: MemoryEngine, request_context):
"""Test _count_observations_for_scope counts observations filtered by tags."""
bank_id = f"test-count-obs-scope-{uuid.uuid4().hex[:8]}"
@@ -2762,6 +2671,7 @@ def _make_mock_llm_one_obs_per_fact():
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_max_observations_per_scope_limits_creates(memory: MemoryEngine, request_context):
"""Mock LLM tries to create 1 obs per fact; with limit=2, only 2 should exist after 5 facts."""
bank_id = f"test-max-obs-limit-{uuid.uuid4().hex[:8]}"
@@ -2818,6 +2728,7 @@ async def test_max_observations_per_scope_limits_creates(memory: MemoryEngine, r
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_max_observations_per_scope_zero_forbids_all_creates(memory: MemoryEngine, request_context):
"""limit=0 means "no new observations": consolidation must create none.
@@ -2871,6 +2782,7 @@ async def test_max_observations_per_scope_zero_forbids_all_creates(memory: Memor
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_max_observations_per_scope_allows_updates_at_capacity(memory: MemoryEngine, request_context):
"""At capacity, the LLM can still update existing observations."""
from hindsight_api.engine.consolidation.consolidator import (
@@ -2971,6 +2883,7 @@ async def test_max_observations_per_scope_allows_updates_at_capacity(memory: Mem
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_max_observations_per_scope_no_tags_skips_limit(memory: MemoryEngine, request_context):
"""With limit=1, memories with no tags should bypass the limit and create freely."""
bank_id = f"test-max-obs-no-tags-{uuid.uuid4().hex[:8]}"
@@ -3006,12 +2919,12 @@ async def test_max_observations_per_scope_no_tags_skips_limit(memory: MemoryEngi
await run_consolidation_job(memory_engine=memory, bank_id=bank_id, request_context=request_context)
# No tag limit should apply — all 3 observations should be created
async with memory._pool.acquire() as conn:
obs = await conn.fetch(
"SELECT id FROM memory_units WHERE bank_id = $1 AND fact_type = 'observation'",
bank_id,
total = (
await memory.list_memory_units(
bank_id, fact_type="observation", limit=1000, request_context=request_context
)
assert len(obs) == 3, f"Expected 3 observations (no limit for no-tag), got {len(obs)}"
)["total"]
assert total == 3, f"Expected 3 observations (no limit for no-tag), got {total}"
finally:
memory._config_resolver._global_config = original_global_config
memory._consolidation_llm_config = original_llm
@@ -3020,6 +2933,7 @@ async def test_max_observations_per_scope_no_tags_skips_limit(memory: MemoryEngi
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_max_observations_unlimited_default(memory: MemoryEngine, request_context):
"""With default config (-1), all creates go through."""
bank_id = f"test-max-obs-unlimited-{uuid.uuid4().hex[:8]}"
@@ -3053,6 +2967,7 @@ async def test_max_observations_unlimited_default(memory: MemoryEngine, request_
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_targeted_consolidation_filters_by_scopes(memory: MemoryEngine, request_context):
"""Consolidation with observation_scopes only processes memories matching those scopes."""
bank_id = f"test-targeted-{uuid.uuid4().hex[:8]}"
@@ -3085,17 +3000,10 @@ async def test_targeted_consolidation_filters_by_scopes(memory: MemoryEngine, re
alice_obs = await _count_observations_for_scope(conn, bank_id, ["user:alice"])
assert alice_obs == 1
# Bob and Charlie should still be unconsolidated
unconsolidated = await conn.fetchval(
"""
SELECT COUNT(*) FROM memory_units
WHERE bank_id = $1
AND consolidated_at IS NULL
AND fact_type IN ('experience', 'world')
""",
bank_id,
)
assert unconsolidated == 2
# Bob and Charlie should still be unconsolidated. consolidation_state='pending'
# is the read API's name for this predicate; it also excludes facts whose
# consolidation failed, which is the stricter (and here equivalent) reading.
assert await _unconsolidated(memory, bank_id, request_context) == 2
# Now consolidate bob
result = await run_consolidation_job(
@@ -3107,23 +3015,14 @@ async def test_targeted_consolidation_filters_by_scopes(memory: MemoryEngine, re
assert result["memories_processed"] == 1
# Charlie still unconsolidated
async with memory._pool.acquire() as conn:
unconsolidated = await conn.fetchval(
"""
SELECT COUNT(*) FROM memory_units
WHERE bank_id = $1
AND consolidated_at IS NULL
AND fact_type IN ('experience', 'world')
""",
bank_id,
)
assert unconsolidated == 1
assert await _unconsolidated(memory, bank_id, request_context) == 1
finally:
memory._consolidation_llm_config = original_llm
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_targeted_consolidation_multiple_scopes(memory: MemoryEngine, request_context):
"""Consolidation with multiple observation_scopes matches memories in any scope."""
bank_id = f"test-targeted-multi-{uuid.uuid4().hex[:8]}"
@@ -3151,23 +3050,14 @@ async def test_targeted_consolidation_multiple_scopes(memory: MemoryEngine, requ
assert result["observations_created"] == 2
# Bob still unconsolidated
async with memory._pool.acquire() as conn:
unconsolidated = await conn.fetchval(
"""
SELECT COUNT(*) FROM memory_units
WHERE bank_id = $1
AND consolidated_at IS NULL
AND fact_type IN ('experience', 'world')
""",
bank_id,
)
assert unconsolidated == 1
assert await _unconsolidated(memory, bank_id, request_context) == 1
finally:
memory._consolidation_llm_config = original_llm
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_targeted_consolidation_no_scopes_processes_all(memory: MemoryEngine, request_context):
"""Consolidation without observation_scopes processes all unconsolidated memories (backward compat)."""
bank_id = f"test-targeted-all-{uuid.uuid4().hex[:8]}"
@@ -3197,6 +3087,7 @@ async def test_targeted_consolidation_no_scopes_processes_all(memory: MemoryEngi
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_targeted_consolidation_contains_semantics(memory: MemoryEngine, request_context):
"""Scope ["user:alice"] matches memories tagged ["user:alice", "team:eng"] (contains)."""
bank_id = f"test-targeted-contains-{uuid.uuid4().hex[:8]}"
@@ -3253,23 +3144,15 @@ async def test_enable_auto_consolidation_flag(memory: MemoryEngine, request_cont
)
# Check that memories are NOT consolidated
async with memory._pool.acquire() as conn:
unconsolidated = await conn.fetchval(
"""
SELECT COUNT(*) FROM memory_units
WHERE bank_id = $1
AND consolidated_at IS NULL
AND fact_type IN ('experience', 'world')
""",
bank_id,
)
assert unconsolidated > 0, "Memories should remain unconsolidated when auto consolidation is disabled"
unconsolidated = await _unconsolidated(memory, bank_id, request_context)
assert unconsolidated > 0, "Memories should remain unconsolidated when auto consolidation is disabled"
observations = await conn.fetchval(
"SELECT COUNT(*) FROM memory_units WHERE bank_id = $1 AND fact_type = 'observation'",
bank_id,
observations = (
await memory.list_memory_units(
bank_id, fact_type="observation", limit=1000, request_context=request_context
)
assert observations == 0, "No observations should be created when auto consolidation is disabled"
)["total"]
assert observations == 0, "No observations should be created when auto consolidation is disabled"
finally:
memory._config_resolver._global_config = original_global_config
await memory.delete_bank(bank_id, request_context=request_context)
@@ -3330,6 +3213,7 @@ def test_consolidation_prompt_split_is_cacheable_and_complete():
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_create_observation_populates_search_vector_native(memory, request_context):
"""Observations created via consolidation must have search_vector populated
when text_search_extension == 'native', so BM25 retrieval finds them."""
@@ -10,6 +10,7 @@ import types
import uuid
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from unittest.mock import DEFAULT, AsyncMock, patch
import pytest
@@ -23,10 +24,19 @@ from hindsight_api.engine.consolidation.consolidator import (
_DedupDecision,
_duplicate_create_target,
_norm_obs_text,
_TemporalBounds,
)
from hindsight_api.engine.memories import RecallArms
from hindsight_api.engine.search.types import RetrievalResult
#: Dates the skipped CREATE would have been stamped with; the fold must carry them onto the twin.
_SOURCE_BOUNDS = _TemporalBounds(
event_date=datetime(2024, 1, 2, tzinfo=timezone.utc),
occurred_start=datetime(2023, 1, 2, tzinfo=timezone.utc),
occurred_end=datetime(2024, 1, 3, tzinfo=timezone.utc),
mentioned_at=datetime(2024, 1, 4, tzinfo=timezone.utc),
)
@dataclass
class _FakeObs:
@@ -195,6 +205,7 @@ def _ctx(threshold: float = 0.97):
create_text="YouTube content in Uzbek is very rich.",
create_source_ids=[uuid.uuid4()],
tags=["t1"],
source_bounds=_SOURCE_BOUNDS,
)
return kwargs, conn, llm
@@ -335,6 +346,15 @@ async def test_dedup_llm_merge_folds_into_twin() -> None:
assert args[1] == "Uzbek content on YouTube is very rich." # merged text persisted
assert args[2] == kwargs["create_source_ids"] # new (live) source facts folded in
assert args[3] == uuid.UUID(_TWIN_ID) # onto the twin row
# ...along with the dates the skipped CREATE carried, so the twin's interval widens (#3477).
# What the SQL *does* with them is covered against a real database in
# test_consolidation_temporal_merge.py — a mocked connection cannot check that.
assert args[5:] == (
_SOURCE_BOUNDS.event_date,
_SOURCE_BOUNDS.occurred_start,
_SOURCE_BOUNDS.occurred_end,
_SOURCE_BOUNDS.mentioned_at,
)
async def test_dedup_llm_merge_sanitizes_text_before_write() -> None:
@@ -401,6 +421,7 @@ def _update_ctx(threshold: float = 0.97):
return kwargs, conn, llm
@pytest.mark.memory_backend_incompatible
async def test_dedup_update_merge_folds_into_twin_and_deletes_updated() -> None:
kwargs, conn, llm = _update_ctx()
llm.call.return_value = _DedupDecision(action="merge", text="Uzbek YouTube content is very rich and growing.")
@@ -167,12 +167,10 @@ async def _insert_memory(conn, bank_id: str, text: str, tags: list[str]) -> uuid
return mem_id
async def _count_observations(memory: MemoryEngine, bank_id: str) -> int:
async with memory._pool.acquire() as conn:
return await conn.fetchval(
"SELECT count(*) FROM memory_units WHERE bank_id = $1 AND fact_type = 'observation'",
bank_id,
)
async def _count_observations(memory: MemoryEngine, bank_id: str, request_context) -> int:
return (
await memory.list_memory_units(bank_id, fact_type="observation", limit=1000, request_context=request_context)
)["total"]
class _TxnSpy:
@@ -196,6 +194,7 @@ class _TxnSpy:
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_recall_failure_cancels_sibling_tag_groups(memory: MemoryEngine, request_context):
"""One group's recall times out → the other two groups are cancelled before
they write, and the job propagates the original error without waiting for
@@ -253,12 +252,13 @@ async def test_recall_failure_cancels_sibling_tag_groups(memory: MemoryEngine, r
# Nothing was written: the cancelled groups never reached their commit,
# and no orphan lands a write after the operation has already failed.
await asyncio.sleep(0.2)
assert await _count_observations(memory, bank_id) == 0
assert await _count_observations(memory, bank_id, request_context) == 0
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_failed_batch_aborts_its_write_group(memory: MemoryEngine, request_context):
"""A batch that raises before its witness commit decides its write-group
abort, rather than leaving it pending for the recovery sweep."""
@@ -292,6 +292,7 @@ async def test_failed_batch_aborts_its_write_group(memory: MemoryEngine, request
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_successful_batch_still_commits_its_write_group(memory: MemoryEngine, request_context):
"""Guard on the abort path: the happy path must still decide commit=True."""
bank_id = f"test-commit-{uuid.uuid4().hex[:8]}"
@@ -122,6 +122,7 @@ class TestAdaptiveBatchSplitting:
"""Verify that a failing batch is halved and retried until batch_size=1 succeeds."""
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_splitting_recovers_all_memories(self, memory_no_llm_verify: MemoryEngine, request_context):
"""When a batch of 2 fails, both are retried individually and succeed."""
bank_id = f"test-split-recovery-{uuid.uuid4().hex[:8]}"
@@ -152,15 +153,11 @@ class TestAdaptiveBatchSplitting:
assert result["memories_failed"] == 0
# Both memories must have consolidated_at set and consolidation_failed_at NULL
async with memory_no_llm_verify._pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, consolidated_at, consolidation_failed_at
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'experience'
""",
bank_id,
rows = (
await memory_no_llm_verify.list_memory_units(
bank_id, fact_type="experience", limit=1000, request_context=request_context
)
)["items"]
assert len(rows) == 2
for row in rows:
assert row["consolidated_at"] is not None, f"Memory {row['id']} should have consolidated_at set"
@@ -175,6 +172,7 @@ class TestAdaptiveBatchSplitting:
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_splitting_with_larger_batch(self, memory_no_llm_verify: MemoryEngine, request_context):
"""A batch of 4 that always fails at size>1 resolves to 4 individual calls."""
bank_id = f"test-split-large-{uuid.uuid4().hex[:8]}"
@@ -206,12 +204,11 @@ class TestAdaptiveBatchSplitting:
assert result["memories_processed"] == 4
assert result["memories_failed"] == 0
async with memory_no_llm_verify._pool.acquire() as conn:
rows = await conn.fetch(
"SELECT consolidated_at, consolidation_failed_at FROM memory_units "
"WHERE bank_id = $1 AND fact_type = 'experience'",
bank_id,
rows = (
await memory_no_llm_verify.list_memory_units(
bank_id, fact_type="experience", limit=1000, request_context=request_context
)
)["items"]
assert all(r["consolidated_at"] is not None for r in rows)
assert all(r["consolidation_failed_at"] is None for r in rows)
@@ -222,6 +219,7 @@ class TestConsolidationFailedAt:
"""Verify that consolidation_failed_at is set — and consolidated_at is NOT — when all retries fail."""
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_single_memory_permanent_failure(self, memory_no_llm_verify: MemoryEngine, request_context):
"""A single memory that exhausts all LLM retries gets consolidation_failed_at, not consolidated_at."""
bank_id = f"test-perm-fail-{uuid.uuid4().hex[:8]}"
@@ -243,11 +241,12 @@ class TestConsolidationFailedAt:
assert result["memories_failed"] == 1
assert result["memories_processed"] == 1
async with memory_no_llm_verify._pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT consolidated_at, consolidation_failed_at FROM memory_units WHERE id = $1",
mem_id,
units = (
await memory_no_llm_verify.list_memory_units(
bank_id, fact_type="experience", limit=1000, request_context=request_context
)
)["items"]
row = next(u for u in units if str(u["id"]) == str(mem_id))
assert row["consolidated_at"] is None, "consolidated_at must NOT be set for a permanently failed memory"
assert row["consolidation_failed_at"] is not None, "consolidation_failed_at must be set"
@@ -255,6 +254,7 @@ class TestConsolidationFailedAt:
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_failed_memory_excluded_from_next_run(self, memory_no_llm_verify: MemoryEngine, request_context):
"""A memory marked consolidation_failed_at is not re-processed on the next consolidation run."""
bank_id = f"test-excluded-{uuid.uuid4().hex[:8]}"
@@ -285,17 +285,19 @@ class TestConsolidationFailedAt:
assert result["memories_processed"] == 0
# Memory still has consolidation_failed_at set and consolidated_at NULL
async with memory_no_llm_verify._pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT consolidated_at, consolidation_failed_at FROM memory_units WHERE id = $1",
mem_id,
units = (
await memory_no_llm_verify.list_memory_units(
bank_id, fact_type="experience", limit=1000, request_context=request_context
)
)["items"]
row = next(u for u in units if str(u["id"]) == str(mem_id))
assert row["consolidated_at"] is None
assert row["consolidation_failed_at"] is not None
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_partial_batch_failure(self, memory_no_llm_verify: MemoryEngine, request_context):
"""In a batch of 2, if only the first individual retry fails, the second still succeeds."""
bank_id = f"test-partial-fail-{uuid.uuid4().hex[:8]}"
@@ -325,15 +327,12 @@ class TestConsolidationFailedAt:
assert result["memories_processed"] == 2
assert result["memories_failed"] == 1
async with memory_no_llm_verify._pool.acquire() as conn:
rows = {
str(r["id"]): r
for r in await conn.fetch(
"SELECT id, consolidated_at, consolidation_failed_at FROM memory_units "
"WHERE bank_id = $1 AND fact_type = 'experience'",
bank_id,
)
}
items = (
await memory_no_llm_verify.list_memory_units(
bank_id, fact_type="experience", limit=1000, request_context=request_context
)
)["items"]
rows = {str(r["id"]): r for r in items}
# One should have failed, one should have succeeded
failed = [r for r in rows.values() if r["consolidation_failed_at"] is not None]
@@ -350,6 +349,7 @@ class TestRecoverConsolidation:
"""Verify the retry_failed_consolidation() method and the /consolidation/recover endpoint."""
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_recover_resets_failed_memories(self, memory_no_llm_verify: MemoryEngine, request_context):
"""retry_failed_consolidation resets consolidation_failed_at and consolidated_at."""
bank_id = f"test-recover-reset-{uuid.uuid4().hex[:8]}"
@@ -375,12 +375,11 @@ class TestRecoverConsolidation:
assert result["retried_count"] == 2
async with memory_no_llm_verify._pool.acquire() as conn:
rows = await conn.fetch(
"SELECT consolidated_at, consolidation_failed_at FROM memory_units "
"WHERE bank_id = $1 AND fact_type = 'experience'",
bank_id,
rows = (
await memory_no_llm_verify.list_memory_units(
bank_id, fact_type="experience", limit=1000, request_context=request_context
)
)["items"]
assert all(r["consolidation_failed_at"] is None for r in rows), "consolidation_failed_at must be cleared"
assert all(r["consolidated_at"] is None for r in rows), "consolidated_at must also be cleared"
@@ -399,6 +398,7 @@ class TestRecoverConsolidation:
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_recover_then_consolidate_succeeds(self, memory_no_llm_verify: MemoryEngine, request_context):
"""After recovery, the memory is picked up by the next consolidation run."""
bank_id = f"test-recover-consolidate-{uuid.uuid4().hex[:8]}"
@@ -425,17 +425,19 @@ class TestRecoverConsolidation:
assert run_result["memories_processed"] == 1
assert run_result["memories_failed"] == 0
async with memory_no_llm_verify._pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT consolidated_at, consolidation_failed_at FROM memory_units WHERE id = $1",
mem_id,
units = (
await memory_no_llm_verify.list_memory_units(
bank_id, fact_type="experience", limit=1000, request_context=request_context
)
)["items"]
row = next(u for u in units if str(u["id"]) == str(mem_id))
assert row["consolidated_at"] is not None, "Memory should be consolidated after recovery"
assert row["consolidation_failed_at"] is None
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_recover_endpoint_via_http(self, memory_no_llm_verify: MemoryEngine, request_context):
"""The POST /consolidation/recover endpoint returns the correct retried_count."""
import httpx
@@ -79,16 +79,16 @@ async def _insert_entity_mm(conn, bank_id: str, tag: str) -> str:
return mm_id
async def _unconsolidated_count(memory, bank_id: str) -> int:
async with memory._pool.acquire() as conn:
return await conn.fetchval(
"""
SELECT COUNT(*) FROM memory_units
WHERE bank_id = $1 AND consolidated_at IS NULL
AND consolidation_failed_at IS NULL AND fact_type IN ('experience', 'world')
""",
bank_id,
)
async def _unconsolidated_count(memory, bank_id: str, request_context) -> int:
# consolidation_state='pending' is the read API's name for exactly this predicate:
# not consolidated, not failed, and a source fact type.
page = await memory.list_memory_units(
bank_id=bank_id,
consolidation_state="pending",
limit=1,
request_context=request_context,
)
return page["total"]
async def _pending_consolidations(memory, bank_id: str):
@@ -105,6 +105,7 @@ async def _pending_consolidations(memory, bank_id: str):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_multi_round_consolidation_refreshes_all_entity_models(
memory: MemoryEngine, request_context, monkeypatch
):
@@ -203,7 +204,7 @@ async def test_multi_round_consolidation_refreshes_all_entity_models(
assert consolidation_runs >= 2, (
f"expected a multi-round drain (round limit 3, backlog > 3), but only {consolidation_runs} consolidation(s) ran"
)
remaining = await _unconsolidated_count(memory, bank_id)
remaining = await _unconsolidated_count(memory, bank_id, request_context)
assert remaining == 0, f"backlog did not fully drain: {remaining} unconsolidated memories remain"
# 6. KEY ASSERTION — every refresh_after_consolidation model must be refreshed
@@ -55,16 +55,16 @@ def enable_observations():
config.enable_observations = original
async def _count_unconsolidated(memory, bank_id: str) -> int:
async with memory._pool.acquire() as conn:
return await conn.fetchval(
"""
SELECT COUNT(*) FROM memory_units
WHERE bank_id = $1 AND consolidated_at IS NULL
AND consolidation_failed_at IS NULL AND fact_type IN ('experience', 'world')
""",
bank_id,
)
async def _count_unconsolidated(memory, bank_id: str, request_context) -> int:
# consolidation_state='pending' is the read API's name for exactly this predicate:
# not consolidated, not failed, and a source fact type.
page = await memory.list_memory_units(
bank_id=bank_id,
consolidation_state="pending",
limit=1,
request_context=request_context,
)
return page["total"]
async def _pending_consolidation_ops(memory, bank_id: str) -> list[str]:
@@ -100,7 +100,7 @@ async def test_round_limited_consolidation_leaves_followup_pending_op(memory: Me
request_context=request_context,
)
unconsolidated_before = await _count_unconsolidated(memory, bank_id)
unconsolidated_before = await _count_unconsolidated(memory, bank_id, request_context)
assert unconsolidated_before >= backlog_size, (
f"Expected at least {backlog_size} unconsolidated memories, got {unconsolidated_before}"
)
@@ -148,7 +148,7 @@ async def test_round_limited_consolidation_leaves_followup_pending_op(memory: Me
assert row["status"] == "completed", f"first consolidation op should be marked completed, got {row['status']}"
# 4. Backlog must remain (round limit kept one round under the total)
unconsolidated_after = await _count_unconsolidated(memory, bank_id)
unconsolidated_after = await _count_unconsolidated(memory, bank_id, request_context)
assert 0 < unconsolidated_after < unconsolidated_before, (
f"expected backlog to shrink but still remain after one round; "
f"before={unconsolidated_before}, after={unconsolidated_after}"
@@ -48,15 +48,13 @@ async def test_round_limit_caps_processed_memories(memory: MemoryEngine, request
)
# Verify we have unconsolidated memories
async with memory._pool.acquire() as conn:
unconsolidated = await conn.fetchval(
"""
SELECT COUNT(*) FROM memory_units
WHERE bank_id = $1 AND consolidated_at IS NULL
AND consolidation_failed_at IS NULL AND fact_type IN ('experience', 'world')
""",
bank_id,
# consolidation_state='pending' is the read API's name for exactly this predicate:
# not consolidated, not failed, and a source fact type.
unconsolidated = (
await memory.list_memory_units(
bank_id=bank_id, consolidation_state="pending", limit=1, request_context=request_context
)
)["total"]
assert unconsolidated >= 6, f"Expected at least 6 unconsolidated memories, got {unconsolidated}"
# Run consolidation with a round limit of 3
@@ -91,15 +89,11 @@ async def test_round_limit_caps_processed_memories(memory: MemoryEngine, request
assert result.get("mental_models_refreshed", 0) == 0
# Verify some memories are still unconsolidated
async with memory._pool.acquire() as conn:
still_unconsolidated = await conn.fetchval(
"""
SELECT COUNT(*) FROM memory_units
WHERE bank_id = $1 AND consolidated_at IS NULL
AND consolidation_failed_at IS NULL AND fact_type IN ('experience', 'world')
""",
bank_id,
still_unconsolidated = (
await memory.list_memory_units(
bank_id=bank_id, consolidation_state="pending", limit=1, request_context=request_context
)
)["total"]
assert still_unconsolidated > 0, "Some memories should still be unconsolidated after hitting round limit"
await memory.delete_bank(bank_id, request_context=request_context)
@@ -140,15 +134,11 @@ async def test_unlimited_round_processes_all(memory: MemoryEngine, request_conte
mock_requeue.assert_not_called()
# All memories should be consolidated
async with memory._pool.acquire() as conn:
still_unconsolidated = await conn.fetchval(
"""
SELECT COUNT(*) FROM memory_units
WHERE bank_id = $1 AND consolidated_at IS NULL
AND consolidation_failed_at IS NULL AND fact_type IN ('experience', 'world')
""",
bank_id,
still_unconsolidated = (
await memory.list_memory_units(
bank_id=bank_id, consolidation_state="pending", limit=1, request_context=request_context
)
)["total"]
assert still_unconsolidated == 0
await memory.delete_bank(bank_id, request_context=request_context)
@@ -122,14 +122,12 @@ def _mock_llm_one_obs_per_fact():
return wrapper, mock_llm
async def _fetch_observation_tag_sets(memory: MemoryEngine, bank_id: str) -> list[frozenset[str]]:
async def _fetch_observation_tag_sets(memory: MemoryEngine, bank_id: str, request_context) -> list[frozenset[str]]:
"""Return the tag set (as a frozenset) of every observation in the bank."""
async with memory._pool.acquire() as conn:
rows = await conn.fetch(
"SELECT tags FROM memory_units WHERE bank_id = $1 AND fact_type = 'observation'",
bank_id,
)
return [frozenset(r["tags"] or []) for r in rows]
items = (
await memory.list_memory_units(bank_id, fact_type="observation", limit=1000, request_context=request_context)
)["items"]
return [frozenset(i["tags"] or []) for i in items]
# ---------------------------------------------------------------------------
@@ -139,6 +137,7 @@ async def _fetch_observation_tag_sets(memory: MemoryEngine, bank_id: str) -> lis
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_combined_mode_parallel_writes_to_memory_tag_set(memory: MemoryEngine, request_context):
"""combined (default) → each memory yields exactly one observation tagged
with the memory's full tag set. With three disjoint tag sets, dispatch
@@ -166,7 +165,7 @@ async def test_combined_mode_parallel_writes_to_memory_tag_set(memory: MemoryEng
memory._consolidation_llm_config = original_llm
assert result["status"] == "completed"
tag_sets = _ag_sorted(await _fetch_observation_tag_sets(memory, bank_id))
tag_sets = _ag_sorted(await _fetch_observation_tag_sets(memory, bank_id, request_context))
assert tag_sets == _ag_sorted(
[
frozenset({"user:alice"}),
@@ -179,6 +178,7 @@ async def test_combined_mode_parallel_writes_to_memory_tag_set(memory: MemoryEng
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_shared_mode_parallel_writes_only_untagged_scope(memory: MemoryEngine, request_context):
"""shared → every memory writes to the single untagged scope, ignoring its
own tags. Three memories with disjoint tags therefore all consolidate into
@@ -207,7 +207,7 @@ async def test_shared_mode_parallel_writes_only_untagged_scope(memory: MemoryEng
memory._consolidation_llm_config = original_llm
assert result["status"] == "completed"
tag_sets = await _fetch_observation_tag_sets(memory, bank_id)
tag_sets = await _fetch_observation_tag_sets(memory, bank_id, request_context)
# Every observation lands at the untagged scope — none carries a session tag.
assert tag_sets and all(t == frozenset() for t in tag_sets), tag_sets
finally:
@@ -215,6 +215,7 @@ async def test_shared_mode_parallel_writes_only_untagged_scope(memory: MemoryEng
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_per_tag_mode_parallel_writes_one_observation_per_tag(memory: MemoryEngine, request_context):
"""per_tag with tags [a, b] → two observations, tagged [a] and [b] respectively.
@@ -246,7 +247,7 @@ async def test_per_tag_mode_parallel_writes_one_observation_per_tag(memory: Memo
assert result["status"] == "completed"
tag_sets = await _fetch_observation_tag_sets(memory, bank_id)
tag_sets = await _fetch_observation_tag_sets(memory, bank_id, request_context)
# M1 writes to [alice]; M2 writes to [alice] and [session]. The mock LLM
# creates one observation per fact per pass, so we expect:
# - one [alice] obs from M1
@@ -263,6 +264,7 @@ async def test_per_tag_mode_parallel_writes_one_observation_per_tag(memory: Memo
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_all_combinations_mode_parallel_writes_every_subset(memory: MemoryEngine, request_context):
"""all_combinations with tags [a, b] → three observations at [a], [b], [a, b]."""
bank_id = f"test-allcombo-{uuid.uuid4().hex[:8]}"
@@ -286,7 +288,7 @@ async def test_all_combinations_mode_parallel_writes_every_subset(memory: Memory
memory._consolidation_llm_config = original_llm
assert result["status"] == "completed"
tag_sets = set(await _fetch_observation_tag_sets(memory, bank_id))
tag_sets = set(await _fetch_observation_tag_sets(memory, bank_id, request_context))
assert tag_sets == {
frozenset({"alice"}),
frozenset({"session"}),
@@ -297,6 +299,7 @@ async def test_all_combinations_mode_parallel_writes_every_subset(memory: Memory
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_explicit_scope_list_parallel_writes_declared_scopes(memory: MemoryEngine, request_context):
"""Explicit list[list[str]] → observations land at exactly those scopes,
regardless of the memory's own tag set."""
@@ -327,7 +330,7 @@ async def test_explicit_scope_list_parallel_writes_declared_scopes(memory: Memor
memory._consolidation_llm_config = original_llm
assert result["status"] == "completed"
tag_sets = set(await _fetch_observation_tag_sets(memory, bank_id))
tag_sets = set(await _fetch_observation_tag_sets(memory, bank_id, request_context))
assert tag_sets == {frozenset({"scope_a"}), frozenset({"scope_b", "scope_c"})}
# And NOT the memory's own tag.
assert frozenset({"tag_ignored"}) not in tag_sets
@@ -342,6 +345,7 @@ async def test_explicit_scope_list_parallel_writes_declared_scopes(memory: Memor
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_overlapping_scopes_serialise_under_parallelism(memory: MemoryEngine, request_context):
"""Two groups whose write-scope sets intersect on scope S must not have
overlapping in-flight LLM-recall windows for S.
@@ -424,6 +428,7 @@ async def test_overlapping_scopes_serialise_under_parallelism(memory: MemoryEngi
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_per_batch_log_line_attributes_only_own_work(memory: MemoryEngine, request_context, caplog):
"""Per-batch log timings / llm_calls / tokens / processed must reflect only
that batch's own work — not totals leaking in from other in-flight batches
@@ -502,6 +507,7 @@ async def test_per_batch_log_line_attributes_only_own_work(memory: MemoryEngine,
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_disjoint_scopes_run_concurrently(memory: MemoryEngine, request_context):
"""When write-scope sets are pairwise disjoint, the dispatcher must let
groups run in parallel we should observe simultaneous in-flight recalls
@@ -58,16 +58,16 @@ def enable_observations():
config.enable_observations = original
async def _count_unconsolidated(memory, bank_id: str) -> int:
async with memory._pool.acquire() as conn:
return await conn.fetchval(
"""
SELECT COUNT(*) FROM memory_units
WHERE bank_id = $1 AND consolidated_at IS NULL
AND consolidation_failed_at IS NULL AND fact_type IN ('experience', 'world')
""",
bank_id,
)
async def _count_unconsolidated(memory, bank_id: str, request_context) -> int:
# consolidation_state='pending' is the read API's name for exactly this predicate:
# not consolidated, not failed, and a source fact type.
page = await memory.list_memory_units(
bank_id=bank_id,
consolidation_state="pending",
limit=1,
request_context=request_context,
)
return page["total"]
@pytest.mark.asyncio
@@ -95,7 +95,7 @@ async def test_requeue_failure_propagates_to_worker_retry(memory: MemoryEngine,
request_context=request_context,
)
unconsolidated_before = await _count_unconsolidated(memory, bank_id)
unconsolidated_before = await _count_unconsolidated(memory, bank_id, request_context)
assert unconsolidated_before >= backlog_size
op_id = uuid.uuid4()
@@ -147,7 +147,7 @@ async def test_requeue_failure_propagates_to_worker_retry(memory: MemoryEngine,
# consolidated stay consolidated; the consolidator's per-batch
# `UPDATE ... SET consolidated_at = NOW()` commits in its own
# transaction (consolidator.py:524-534), not inside the op-level state.
unconsolidated_after = await _count_unconsolidated(memory, bank_id)
unconsolidated_after = await _count_unconsolidated(memory, bank_id, request_context)
assert unconsolidated_after < unconsolidated_before, (
f"completed-round work must be durable across the re-queue failure; "
f"before={unconsolidated_before}, after={unconsolidated_after}"
@@ -0,0 +1,363 @@
"""Observation merges must never narrow an observation's temporal bounds (#3477).
Every path that folds evidence into an existing observation the ordinary UPDATE, the
CREATE-time semantic dedup fold, the UPDATE-time dedup fold has to widen
``event_date``/``occurred_start`` to the earliest known value and
``occurred_end``/``mentioned_at`` to the latest. Before the fix the dedup folds rewrote only
text/sources, so an observation could cite dated source facts while reporting no event
interval at all.
The SQL-executing tests here run against a real database on purpose: the merge lives in SQL,
and asserting on a mocked connection cannot tell a working statement from one PostgreSQL
refuses to plan.
"""
import types
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, patch
import pytest
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.consolidation import consolidator
from hindsight_api.engine.consolidation.consolidator import (
_aggregate_source_fields,
_DedupDecision,
_DedupOutcome,
_execute_create_action,
_execute_update_action,
_TemporalBounds,
run_consolidation_job,
)
from hindsight_api.engine.memory_engine import MemoryEngine
from hindsight_api.engine.response_models import MemoryFact
EARLY = datetime(2020, 3, 1, tzinfo=timezone.utc)
LATE = datetime(2021, 7, 4, 12, 30, tzinfo=timezone.utc)
@pytest.fixture
def observations_enabled():
"""Observations on, and a loose dedup gate so the near-duplicate CREATE reaches the fold."""
config = _get_raw_config()
previous_observations = config.enable_observations
previous_threshold = config.consolidation_dedup_threshold
config.enable_observations = True
config.consolidation_dedup_threshold = 0.5
yield config
config.enable_observations = previous_observations
config.consolidation_dedup_threshold = previous_threshold
async def _retain_fact(
memory: MemoryEngine,
request_context,
config,
bank_id: str,
content: str,
marker: str,
bounds: _TemporalBounds = _TemporalBounds(),
) -> uuid.UUID:
"""Retain ``content`` and return the id of the resulting fact containing ``marker``.
The mock extractor also emits facts for the extraction prompt's own boilerplate; those are
marked consolidated so only the fact under test can join a later consolidation job. Retain
runs with observations off so the caller controls when consolidation happens.
"""
previous = config.enable_observations
config.enable_observations = False
try:
await memory.retain_async(bank_id=bank_id, content=content, request_context=request_context)
finally:
config.enable_observations = previous
async with memory._pool.acquire() as conn:
await conn.execute(
"""
UPDATE memory_units SET consolidated_at = now()
WHERE bank_id = $1 AND fact_type <> 'observation' AND consolidated_at IS NULL
AND text NOT LIKE $2
""",
bank_id,
f"%{marker}%",
)
# Repeat the marker predicate rather than leaning on the sweep above: if the extractor
# ever emits two facts for this content, stamping both and returning an arbitrary one
# would make the caller's source-id assertions flaky rather than fail here.
stamped = await conn.fetch(
"""
UPDATE memory_units
SET event_date = $2, occurred_start = $3, occurred_end = $4, mentioned_at = $5
WHERE bank_id = $1 AND fact_type <> 'observation' AND consolidated_at IS NULL
AND text LIKE $6
RETURNING id
""",
bank_id,
bounds.event_date,
bounds.occurred_start,
bounds.occurred_end,
bounds.mentioned_at,
f"%{marker}%",
)
assert len(stamped) == 1, f"expected exactly one unconsolidated fact matching {marker!r}, got {len(stamped)}"
return stamped[0]["id"]
async def _seed_undated_observation(
memory: MemoryEngine, bank_id: str, source_fact_id: uuid.UUID, text: str
) -> uuid.UUID:
"""Create an observation the way undated source facts do: stamped with a consolidation-time
``event_date``/``mentioned_at`` and no occurred interval at all."""
action = await _execute_create_action(
pool=await memory._get_backend(),
memory_engine=memory,
bank_id=bank_id,
source_memory_ids=[source_fact_id],
text=text,
)
assert action == "created"
async with memory._pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id, occurred_start FROM memory_units WHERE bank_id = $1 AND fact_type = 'observation'",
bank_id,
)
assert row["occurred_start"] is None
return row["id"]
async def _observations(memory: MemoryEngine, bank_id: str) -> list:
async with memory._pool.acquire() as conn:
return await conn.fetch(
"""
SELECT id, text, event_date, occurred_start, occurred_end, mentioned_at, source_memory_ids
FROM memory_units WHERE bank_id = $1 AND fact_type = 'observation' ORDER BY created_at
""",
bank_id,
)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_dedup_create_fold_widens_bounds_of_the_twin(memory: MemoryEngine, request_context, observations_enabled):
"""#3477: a CREATE folded into a near-twin must hand over its source facts' dates.
Without this the twin ends up citing a dated source fact while still reporting
``occurred_start``/``occurred_end`` as NULL the exact shape reported in the issue.
"""
bank_id = f"test-temporal-fold-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
undated_fact = await _retain_fact(
memory, request_context, observations_enabled, bank_id, "Alice moved to Berlin for work.", "Alice"
)
observation_id = await _seed_undated_observation(memory, bank_id, undated_fact, "Alice moved to Berlin for work.")
dated_fact = await _retain_fact(
memory,
request_context,
observations_enabled,
bank_id,
"Alice relocated to Berlin for a new job.",
"relocated",
_TemporalBounds(event_date=EARLY, occurred_start=EARLY, occurred_end=LATE, mentioned_at=LATE),
)
# The adjudicating LLM votes "merge", so this CREATE folds into the twin instead of inserting.
with patch.object(
consolidator,
"_dedup_decision_from_response",
lambda _response: _DedupDecision(action="merge", text="Alice moved to Berlin for a new job.", reason="test"),
):
await run_consolidation_job(memory_engine=memory, bank_id=bank_id, request_context=request_context)
rows = await _observations(memory, bank_id)
assert len(rows) == 1, f"the CREATE should have folded into the twin, not inserted: {[dict(r) for r in rows]}"
folded = rows[0]
assert folded["id"] == observation_id
assert dated_fact in folded["source_memory_ids"], "the dated fact must be cited by the survivor"
assert folded["occurred_start"] == EARLY
assert folded["occurred_end"] == LATE
assert folded["event_date"] == EARLY, "the source's earlier event_date must win over the stamped one"
assert folded["mentioned_at"] > LATE, "mentioned_at keeps the later of the two (the stamped one)"
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_update_widens_bounds_from_its_source_facts(memory: MemoryEngine, request_context, observations_enabled):
"""The ordinary UPDATE path inherits every temporal field from its sources, event_date included."""
bank_id = f"test-temporal-update-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
undated_fact = await _retain_fact(
memory, request_context, observations_enabled, bank_id, "Bob plays the cello.", "cello"
)
observation_id = await _seed_undated_observation(memory, bank_id, undated_fact, "Bob plays the cello.")
stamped = (await _observations(memory, bank_id))[0]
dated_fact = await _retain_fact(
memory,
request_context,
observations_enabled,
bank_id,
"Bob joined a string quartet.",
"quartet",
_TemporalBounds(event_date=EARLY, occurred_start=EARLY, occurred_end=LATE, mentioned_at=LATE),
)
embedding = await _execute_update_action(
pool=await memory._get_backend(),
memory_engine=memory,
bank_id=bank_id,
source_memory_ids=[dated_fact],
observation_id=str(observation_id),
new_text="Bob plays the cello in a string quartet.",
observations=[
MemoryFact(
id=str(observation_id),
text=stamped["text"],
fact_type="observation",
source_fact_ids=[str(undated_fact)],
tags=[],
)
],
source_bounds=_TemporalBounds(event_date=EARLY, occurred_start=EARLY, occurred_end=LATE, mentioned_at=LATE),
)
assert embedding is not None, "the update must have landed"
updated = (await _observations(memory, bank_id))[0]
assert updated["occurred_start"] == EARLY
assert updated["occurred_end"] == LATE
assert updated["event_date"] == EARLY, "event_date must follow the sources, not stay at creation time"
assert updated["mentioned_at"] == stamped["mentioned_at"], "the stamped mention is later than the source's"
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_dedup_update_fold_unions_the_bounds_of_both_rows(
memory: MemoryEngine, request_context, observations_enabled
):
"""The UPDATE-time fold deletes the folded-from row, so the survivor must absorb its dates."""
bank_id = f"test-temporal-updfold-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
twin_fact = await _retain_fact(
memory, request_context, observations_enabled, bank_id, "Carla ran the Rome marathon.", "marathon"
)
updated_fact = await _retain_fact(
memory, request_context, observations_enabled, bank_id, "Carla trains every morning.", "trains"
)
twin_text = "Carla ran the Rome marathon."
updated_text = "Carla trains every morning and ran the Rome marathon."
await _execute_create_action(
pool=await memory._get_backend(),
memory_engine=memory,
bank_id=bank_id,
source_memory_ids=[twin_fact],
text=twin_text,
occurred_start=EARLY,
)
await _execute_create_action(
pool=await memory._get_backend(),
memory_engine=memory,
bank_id=bank_id,
source_memory_ids=[updated_fact],
text=updated_text,
occurred_end=LATE,
)
rows = {row["text"]: row for row in await _observations(memory, bank_id)}
twin_id, updated_id = rows[twin_text]["id"], rows[updated_text]["id"]
assert rows[twin_text]["occurred_end"] is None and rows[updated_text]["occurred_start"] is None
# The re-embedded UPDATE drifted onto the twin and the LLM votes "merge".
with patch.object(
consolidator,
"_dedup_adjudicate",
AsyncMock(
return_value=_DedupOutcome(
best_id=str(twin_id), merged_text=twin_text, should_merge=True, best_text=twin_text
)
),
):
await consolidator._dedup_reconcile_update(
await memory._get_backend(),
memory,
bank_id,
observations_enabled,
None,
str(updated_id),
updated_text,
None,
[],
)
survivors = await _observations(memory, bank_id)
assert [row["id"] for row in survivors] == [twin_id], "the folded-from row must be gone"
assert survivors[0]["occurred_start"] == EARLY, "the twin keeps its own start"
assert survivors[0]["occurred_end"] == LATE, "and absorbs the deleted row's end"
assert updated_fact in survivors[0]["source_memory_ids"]
await memory.delete_bank(bank_id, request_context=request_context)
def test_temporal_bounds_merge_keeps_the_widest_known_interval():
"""min/max per field, with a missing value on either side ignored."""
merged = _TemporalBounds(event_date=LATE, occurred_start=LATE, occurred_end=EARLY).merged_with(
_TemporalBounds(event_date=EARLY, occurred_end=LATE, mentioned_at=LATE)
)
assert merged == _TemporalBounds(event_date=EARLY, occurred_start=LATE, occurred_end=LATE, mentioned_at=LATE)
assert _TemporalBounds().merged_with(_TemporalBounds()) == _TemporalBounds()
def test_temporal_bounds_of_reads_a_source_aggregation():
"""``_aggregate_source_fields`` is where an observation's dates come from, so its result has
to hand over all four fields unchanged."""
aggregation = _aggregate_source_fields(
[
{"event_date": LATE, "occurred_start": LATE, "occurred_end": LATE, "mentioned_at": EARLY},
{"event_date": EARLY, "occurred_start": EARLY, "occurred_end": None, "mentioned_at": LATE},
]
)
assert _TemporalBounds.of(aggregation) == _TemporalBounds(
event_date=EARLY, occurred_start=EARLY, occurred_end=LATE, mentioned_at=LATE
)
@pytest.mark.asyncio
async def test_store_owned_fold_merges_bounds_like_the_sql_path():
"""A memories store that owns its rows re-upserts the whole observation, so the fold has to
apply the same widening in Python."""
stored = types.SimpleNamespace(
source_memory_ids=["existing"],
tags=["t1"],
created_at=EARLY,
event_date=LATE,
occurred_start=LATE,
occurred_end=EARLY,
mentioned_at=EARLY,
)
store = types.SimpleNamespace(get_memories=AsyncMock(return_value=[stored]), upsert_observation=AsyncMock())
with patch.object(consolidator.embedding_utils, "generate_embeddings_batch", AsyncMock(return_value=[[0.1, 0.2]])):
await consolidator._reconcile_merge_via_store(
store,
conn=object(),
memory_engine=types.SimpleNamespace(embeddings=object()),
bank_id="bank-1",
observation_id=str(uuid.uuid4()),
merged_text="merged",
add_source_ids=[uuid.uuid4()],
add_bounds=_TemporalBounds(event_date=EARLY, occurred_end=LATE, mentioned_at=LATE),
)
record = store.upsert_observation.await_args.kwargs["record"]
assert record.event_date == EARLY
assert record.occurred_start == LATE
assert record.occurred_end == LATE
assert record.mentioned_at == LATE
assert record.created_at == EARLY, "fields the fold does not own are preserved"
@@ -32,6 +32,7 @@ async def _seed(conn, bank_id: str, *, tags: list[str], consolidated: bool = Fal
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_count_dedupes_across_overlapping_scopes(memory: MemoryEngine, request_context: RequestContext):
bank_id = f"test-count-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
@@ -7,6 +7,7 @@ test_memory_curation.py.
"""
import uuid
from unittest.mock import patch
import httpx
import pytest
@@ -47,6 +48,7 @@ async def _insert_fact(memory: MemoryEngine, bank_id: str, text: str) -> str:
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_patch_invalidate_and_revert_over_http(api_client, memory):
bank_id = f"curation-http-{uuid.uuid4().hex[:8]}"
mem_id = await _insert_fact(memory, bank_id, "srv-04 runs PostgreSQL 14.")
@@ -79,6 +81,7 @@ async def test_patch_invalidate_and_revert_over_http(api_client, memory):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_patch_clears_occurred_dates_with_explicit_null(api_client, memory):
bank_id = f"curation-http-clear-dates-{uuid.uuid4().hex[:8]}"
mem_id = await _insert_fact(memory, bank_id, "Release v1.2 happened on Monday.")
@@ -134,3 +137,41 @@ async def test_patch_empty_body_is_rejected(api_client, memory):
)
assert resp.status_code == 422
await memory.delete_bank(bank_id, request_context=RequestContext())
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_patch_resolve_entities_reaches_the_engine(api_client, memory):
"""resolve_entities must survive the HTTP boundary, and default to True when omitted (#3479)."""
bank_id = f"curation-http-resolve-{uuid.uuid4().hex[:8]}"
mem_id = await _insert_fact(memory, bank_id, "Dr. Waller referred the patient.")
seen: list[bool] = []
real_update = memory.update_memory_unit
async def _capture(*args, **kwargs):
seen.append(kwargs.get("resolve_entities"))
return await real_update(*args, **kwargs)
with patch.object(memory, "update_memory_unit", new=_capture):
resp = await api_client.patch(
f"/v1/default/banks/{bank_id}/memories/{mem_id}",
json={"entities": ["Dr. Waller"], "resolve_entities": False},
)
assert resp.status_code == 200, resp.text
resp = await api_client.patch(
f"/v1/default/banks/{bank_id}/memories/{mem_id}",
json={"entities": ["Dr. Waller"]},
)
assert resp.status_code == 200, resp.text
assert seen == [False, True], "an explicit flag is forwarded; omitting it defaults to resolving"
# A non-boolean is rejected by the request model, not silently coerced.
resp = await api_client.patch(
f"/v1/default/banks/{bank_id}/memories/{mem_id}",
json={"entities": ["Dr. Waller"], "resolve_entities": "exact"},
)
assert resp.status_code == 422
await memory.delete_bank(bank_id, request_context=RequestContext())
@@ -27,6 +27,10 @@ from hindsight_api.engine.task_backend import SyncTaskBackend
from hindsight_api.extensions import TenantContext, TenantExtension
from hindsight_api.migrations import ensure_embedding_dimension, run_migrations
# The whole point of this module is the physical embedding column: it counts
# memory_units rows with a non-NULL embedding of a configured dimension.
pytestmark = pytest.mark.memory_backend_incompatible
# =============================================================================
# Shared Utilities
# =============================================================================
@@ -13,6 +13,7 @@ import pytest
from hindsight_api.engine.db import DatabaseBackend, DatabaseConnection, create_database_backend
from hindsight_api.engine.db.ops import UpdatedWindow
from hindsight_api.engine.db import postgresql as pg_backend
from hindsight_api.engine.db.postgresql import PostgreSQLBackend, apply_session_settings
from hindsight_api.engine.db.result import DictResultRow as ResultRow
from hindsight_api.engine.sql import SQLDialect, create_sql_dialect
@@ -645,6 +646,13 @@ class _RecordingConnection:
class TestApplySessionSettings:
"""The pool's setup callback runs on every acquire — it must be one round trip (#3499)."""
@pytest.fixture(autouse=True)
def _forget_rejected_settings(self):
"""The rejected-GUC memo is process-wide, so it must not leak between tests."""
pg_backend._unsupported_settings.clear()
yield
pg_backend._unsupported_settings.clear()
_SETTINGS = [
("hnsw.ef_search", "200"),
("statement_timeout", "600s"),
@@ -684,6 +692,44 @@ class TestApplySessionSettings:
# The rejected one raised and was skipped rather than aborting setup.
assert len(conn.calls) == 1 + len(self._SETTINGS)
@pytest.mark.asyncio
async def test_a_setting_the_server_rejects_is_not_sent_again(self):
"""Otherwise every acquire re-pays a failed batch plus one statement per setting.
Reached by any GUC the cluster does not define pg_trgm when the extension is
absent, or hnsw.iterative_scan on a pgvector older than 0.8, which reserves the
"hnsw." prefix and so rejects it rather than accepting a placeholder.
"""
conn = _RecordingConnection(fail_batched=True, reject="pg_trgm.similarity_threshold")
await apply_session_settings(conn, self._SETTINGS)
# Next acquire: one batched statement again, carrying only what the server took.
conn = _RecordingConnection()
await apply_session_settings(conn, self._SETTINGS)
assert len(conn.calls) == 1
_, args = conn.calls[0]
assert "pg_trgm.similarity_threshold" not in args
assert args == ("hnsw.ef_search", "200", "statement_timeout", "600s")
@pytest.mark.asyncio
async def test_a_transient_failure_does_not_disable_a_setting(self):
"""Only "unrecognized configuration parameter" is permanent; anything else retries."""
class _FlakyConnection(_RecordingConnection):
async def execute(self, query: str, *args) -> None:
self.calls.append((query, args))
if len(self.calls) == 1:
raise asyncpg.exceptions.UndefinedObjectError("unrecognized configuration parameter")
if "hnsw.ef_search" in args:
raise asyncpg.exceptions.DeadlockDetectedError("transient")
await apply_session_settings(_FlakyConnection(), self._SETTINGS)
conn = _RecordingConnection()
await apply_session_settings(conn, self._SETTINGS)
assert "hnsw.ef_search" in conn.calls[0][1]
# ---------------------------------------------------------------------------
# Config integration test
+29 -78
View File
@@ -2,7 +2,6 @@
Tests for delta retain upsert optimization that only re-processes changed chunks.
"""
import json
import logging
from datetime import datetime, timezone
@@ -237,6 +236,7 @@ async def test_delta_retain_modified_chunk(memory, request_context):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_delta_retain_entities_preserved_for_unchanged_chunks(memory, request_context):
"""
Entities linked to unchanged chunks should be preserved after delta retain.
@@ -256,13 +256,8 @@ async def test_delta_retain_entities_preserved_for_unchanged_chunks(memory, requ
assert len(v1_units) > 0
# Check entities exist
pool = await memory._get_pool()
async with pool.acquire() as conn:
v1_entities = await conn.fetch(
"SELECT canonical_name FROM entities WHERE bank_id = $1",
bank_id,
)
v1_entity_names = {e["canonical_name"].lower() for e in v1_entities}
v1_listing = await memory.list_entities(bank_id, request_context=request_context)
v1_entity_names = {e["canonical_name"].lower() for e in v1_listing["items"]}
assert len(v1_entity_names) > 0, "Should have entities after v1 retain"
# Upsert with same content — entities should persist
@@ -274,12 +269,8 @@ async def test_delta_retain_entities_preserved_for_unchanged_chunks(memory, requ
request_context=request_context,
)
async with pool.acquire() as conn:
v2_entities = await conn.fetch(
"SELECT canonical_name FROM entities WHERE bank_id = $1",
bank_id,
)
v2_entity_names = {e["canonical_name"].lower() for e in v2_entities}
v2_listing = await memory.list_entities(bank_id, request_context=request_context)
v2_entity_names = {e["canonical_name"].lower() for e in v2_listing["items"]}
# All v1 entities should still exist
assert v1_entity_names.issubset(v2_entity_names), (
@@ -308,13 +299,8 @@ async def test_delta_retain_new_entities_created_for_new_chunks(memory, request_
request_context=request_context,
)
pool = await memory._get_pool()
async with pool.acquire() as conn:
v1_entities = await conn.fetch(
"SELECT canonical_name FROM entities WHERE bank_id = $1",
bank_id,
)
v1_entity_names = {e["canonical_name"].lower() for e in v1_entities}
v1_listing = await memory.list_entities(bank_id, request_context=request_context)
v1_entity_names = {e["canonical_name"].lower() for e in v1_listing["items"]}
# Append content mentioning new entities
v2_content = v1_content + "\n\nBob joined Facebook. He works with Charlie on the Reality Labs project."
@@ -326,12 +312,8 @@ async def test_delta_retain_new_entities_created_for_new_chunks(memory, request_
request_context=request_context,
)
async with pool.acquire() as conn:
v2_entities = await conn.fetch(
"SELECT canonical_name FROM entities WHERE bank_id = $1",
bank_id,
)
v2_entity_names = {e["canonical_name"].lower() for e in v2_entities}
v2_listing = await memory.list_entities(bank_id, request_context=request_context)
v2_entity_names = {e["canonical_name"].lower() for e in v2_listing["items"]}
# Should have more entities after adding content with new people/orgs
assert len(v2_entity_names) > len(v1_entity_names), (
@@ -343,6 +325,7 @@ async def test_delta_retain_new_entities_created_for_new_chunks(memory, request_
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_delta_retain_links_preserved_for_unchanged_chunks(memory, request_context):
"""
Memory links (temporal, semantic, entity) for unchanged chunks should be preserved.
@@ -444,6 +427,7 @@ async def test_delta_retain_document_metadata_updated(memory, request_context):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_delta_retain_metadata_consistent_for_unchanged_units(memory, request_context):
"""Metadata updates should reach facts preserved by metadata-only retain."""
bank_id = f"test_delta_unit_meta_{_ts()}"
@@ -479,25 +463,19 @@ async def test_delta_retain_metadata_consistent_for_unchanged_units(memory, requ
assert doc is not None
assert doc["document_metadata"] == {"source": "crm"}
pool = await memory._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT metadata FROM memory_units WHERE bank_id = $1 AND document_id = $2",
bank_id,
document_id,
)
listing = await memory.list_memory_units(bank_id, document_id=document_id, request_context=request_context)
rows = listing["items"]
assert rows
for row in rows:
metadata = row["metadata"]
if isinstance(metadata, str):
metadata = json.loads(metadata)
assert metadata == {"source": "crm"}
# list_memory_units already parses the JSON metadata into a dict.
assert row["metadata"] == {"source": "crm"}
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_delta_retain_drops_null_metadata_values(memory, request_context):
"""A null-valued metadata key must never reach memory_units (issue #3209).
@@ -519,21 +497,12 @@ async def test_delta_retain_drops_null_metadata_values(memory, request_context):
async def _unit_metadata() -> dict[str, dict]:
"""The document's memory units, keyed by unit id, so a later call can
tell units that survived a delta from ones re-extracted from scratch."""
pool = await memory._get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, metadata FROM memory_units WHERE bank_id = $1 AND document_id = $2",
bank_id,
document_id,
)
listing = await memory.list_memory_units(bank_id, document_id=document_id, request_context=request_context)
rows = listing["items"]
assert rows, "expected memory units for the document"
units = {}
for row in rows:
metadata = row["metadata"]
if isinstance(metadata, str):
metadata = json.loads(metadata)
units[str(row["id"])] = metadata
return units
# list_memory_units already parses the JSON metadata into a dict and
# returns ids as strings.
return {row["id"]: row["metadata"] for row in rows}
async def _retain(content: str, source: str) -> None:
await memory.retain_batch_async(
@@ -598,13 +567,8 @@ async def test_delta_retain_tags_propagated_to_existing_units(memory, request_co
request_context=request_context,
)
pool = await memory._get_pool()
async with pool.acquire() as conn:
v1_tags = await conn.fetch(
"SELECT tags FROM memory_units WHERE bank_id = $1 AND document_id = $2",
bank_id,
document_id,
)
v1_listing = await memory.list_memory_units(bank_id, document_id=document_id, request_context=request_context)
v1_tags = v1_listing["items"]
assert all("team-a" in row["tags"] for row in v1_tags), "v1 units should have team-a tag"
# v2 with same content but different tags
@@ -620,12 +584,8 @@ async def test_delta_retain_tags_propagated_to_existing_units(memory, request_co
request_context=request_context,
)
async with pool.acquire() as conn:
v2_tags = await conn.fetch(
"SELECT tags FROM memory_units WHERE bank_id = $1 AND document_id = $2",
bank_id,
document_id,
)
v2_listing = await memory.list_memory_units(bank_id, document_id=document_id, request_context=request_context)
v2_tags = v2_listing["items"]
for row in v2_tags:
assert "team-b" in row["tags"], f"v2 units should have team-b tag, got {row['tags']}"
assert "important" in row["tags"], f"v2 units should have important tag, got {row['tags']}"
@@ -923,13 +883,8 @@ async def test_delta_retain_with_user_entities(memory, request_context):
request_context=request_context,
)
pool = await memory._get_pool()
async with pool.acquire() as conn:
v1_entities = await conn.fetch(
"SELECT canonical_name FROM entities WHERE bank_id = $1",
bank_id,
)
v1_names = {e["canonical_name"].lower() for e in v1_entities}
v1_listing = await memory.list_entities(bank_id, request_context=request_context)
v1_names = {e["canonical_name"].lower() for e in v1_listing["items"]}
# v2 with additional entity, same content
# Note: same content = delta path (no re-extraction)
@@ -951,12 +906,8 @@ async def test_delta_retain_with_user_entities(memory, request_context):
)
# Should have entities from both v1 and v2
async with pool.acquire() as conn:
v2_entities = await conn.fetch(
"SELECT canonical_name FROM entities WHERE bank_id = $1",
bank_id,
)
v2_names = {e["canonical_name"].lower() for e in v2_entities}
v2_listing = await memory.list_entities(bank_id, request_context=request_context)
v2_names = {e["canonical_name"].lower() for e in v2_listing["items"]}
# v1 entities should be preserved
assert v1_names.issubset(v2_names), f"v1 entities should be preserved: {v1_names} not in {v2_names}"
@@ -114,13 +114,11 @@ async def test_delta_detects_unchanged_after_first_retain(memory, request_contex
)
assert len(v1_units) > 0
pool = await memory._get_pool()
async with pool.acquire() as conn:
v1_count = await conn.fetchval(
"SELECT count(*) FROM memory_units WHERE bank_id = $1 AND document_id = $2",
bank_id,
document_id,
)
async def _unit_count() -> int:
listing = await memory.list_memory_units(bank_id, document_id=document_id, request_context=request_context)
return listing["total"]
v1_count = await _unit_count()
# Second retain — same content, should be detected as unchanged by delta
v2_units = await memory.retain_async(
@@ -135,12 +133,7 @@ async def test_delta_detects_unchanged_after_first_retain(memory, request_contex
assert v2_units == [], f"Delta with unchanged content should return empty, got {len(v2_units)} units"
# Memory unit count should not change
async with pool.acquire() as conn:
v2_count = await conn.fetchval(
"SELECT count(*) FROM memory_units WHERE bank_id = $1 AND document_id = $2",
bank_id,
document_id,
)
v2_count = await _unit_count()
assert v2_count == v1_count, f"Memory unit count changed on same-content upsert: {v1_count} -> {v2_count}"
# Third retain — verify stability
@@ -153,12 +146,7 @@ async def test_delta_detects_unchanged_after_first_retain(memory, request_contex
)
assert v3_units == [], "Third retain should also detect unchanged"
async with pool.acquire() as conn:
v3_count = await conn.fetchval(
"SELECT count(*) FROM memory_units WHERE bank_id = $1 AND document_id = $2",
bank_id,
document_id,
)
v3_count = await _unit_count()
assert v3_count == v1_count, f"Memory unit count changed on third upsert: {v1_count} -> {v3_count}"
finally:
@@ -189,18 +177,19 @@ async def test_stale_request_skipped_when_newer_retain_completed(memory, request
request_context=request_context,
)
pool = await memory._get_pool()
async with pool.acquire() as conn:
after_newer_count = await conn.fetchval(
"SELECT count(*) FROM memory_units WHERE bank_id = $1 AND document_id = $2",
bank_id,
document_id,
)
async def _unit_count() -> int:
listing = await memory.list_memory_units(bank_id, document_id=document_id, request_context=request_context)
return listing["total"]
after_newer_count = await _unit_count()
assert after_newer_count > 0, "Should have facts from newer content"
# Simulate the race condition by pushing the document's updated_at into
# the future. This makes any new retain appear "stale" (its start_time
# is before updated_at), as if another request already completed.
# is before updated_at), as if another request already completed. This
# forces internal store state (a document's updated_at) that the public
# API has no way to set, so it stays a direct write on purpose.
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE documents SET updated_at = NOW() + INTERVAL '10 seconds' WHERE id = $1 AND bank_id = $2",
@@ -223,12 +212,7 @@ async def test_stale_request_skipped_when_newer_retain_completed(memory, request
assert result == [], f"Stale request should return empty, got {result}"
# Memory units should be unchanged (newer content preserved)
async with pool.acquire() as conn:
final_count = await conn.fetchval(
"SELECT count(*) FROM memory_units WHERE bank_id = $1 AND document_id = $2",
bank_id,
document_id,
)
final_count = await _unit_count()
assert final_count == after_newer_count, (
f"Stale request should not change memory units: {after_newer_count} -> {final_count}"
)
@@ -271,6 +255,7 @@ async def memory_no_llm(pg0_db_url, embeddings, cross_encoder, query_analyzer):
@pytest.mark.asyncio
@pytest.mark.flaky(reruns=2, reruns_delay=2)
@pytest.mark.memory_backend_incompatible
async def test_concurrent_upserts_no_duplicates(memory_no_llm, request_context):
"""
Stress test: N concurrent retains of the same document with different content.
@@ -350,12 +335,10 @@ async def test_concurrent_upserts_no_duplicates(memory_no_llm, request_context):
logger.info(f"Winning version: {winning_version} (out of {num_concurrent} concurrent retains)")
# 2. All memory units should belong to the winning version
async with pool.acquire() as conn:
units = await conn.fetch(
"SELECT text, chunk_id, id::text as unit_id FROM memory_units WHERE bank_id = $1 AND document_id = $2",
bank_id,
document_id,
)
listing = await memory_no_llm.list_memory_units(
bank_id, document_id=document_id, limit=1000, request_context=request_context
)
units = listing["items"]
unit_texts = [r["text"] for r in units]
assert len(unit_texts) > 0, "Should have at least 1 memory unit"
@@ -364,9 +347,7 @@ async def test_concurrent_upserts_no_duplicates(memory_no_llm, request_context):
# We check for "Person_N" rather than "VERSION_N" because the text
# splitter may cut mid-text, so later chunks might not start with the prefix.
winning_person = f"Person_{winning_version}"
wrong_version_units = [
(r["text"], r["chunk_id"], r["unit_id"]) for r in units if winning_person not in r["text"]
]
wrong_version_units = [(r["text"], r["chunk_id"], r["id"]) for r in units if winning_person not in r["text"]]
assert not wrong_version_units, (
f"Found {len(wrong_version_units)} memory units NOT from winning version "
f"{winning_version} (expected '{winning_person}' in every unit). "
@@ -115,6 +115,7 @@ async def _export_async(memory, bank_id, request_context, **kwargs):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_import_filters_degenerate_fact_without_shifting_archive_ordinals(memory, request_context):
"""A rejected archive fact must not shift chunks, causal links, or observation sources."""
dst = _unique_bank("transfer_degenerate_alignment")
@@ -393,6 +394,7 @@ async def test_export_bank_contents(memory, request_context):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_export_tolerates_legacy_null_and_numeric_fact_metadata(memory, request_context):
"""A bank holding legacy metadata must still be exportable (issue #3209).
@@ -522,6 +524,7 @@ async def _observation_count(memory, bank_id):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_bank_import_preserves_consolidation_lifecycle(memory, request_context):
"""Whole-bank import restores each fact's consolidation lifecycle verbatim, so
previously-consolidated and previously-failed facts are never re-consolidated
@@ -622,6 +625,7 @@ async def test_bank_import_preserves_consolidation_lifecycle(memory, request_con
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_bank_export_import_exact_roundtrip(memory, request_context):
"""A whole-bank archive restores EXACT bank content (config, docs, facts,
observations, entities, links, webhooks, directives, mental models) with facts
@@ -793,6 +797,7 @@ async def test_bank_roundtrip_carries_mental_model_history(memory, request_conte
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_bank_roundtrip_carries_knowledge_pages(memory, request_context):
"""A whole-bank archive restores the Knowledge Pages tree — nested folders +
pages, parent_id / mental_model_id / managed / sort_order preserved and
@@ -896,6 +901,7 @@ async def test_import_bank_refuses_existing_bank(memory, request_context):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_export_import_roundtrip_without_llm(memory, request_context, monkeypatch):
"""Export from one bank and import into another without re-running the LLM."""
src = _unique_bank("transfer_src")
@@ -1019,6 +1025,7 @@ async def _bank_snapshot(memory, bank_id):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_full_roundtrip_integrity(memory, request_context):
"""Full export → import must reproduce every persisted artifact (counts + sizes)."""
src = _unique_bank("transfer_integ_src")
@@ -1072,6 +1079,7 @@ async def test_full_roundtrip_integrity(memory, request_context):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_transfer_preserves_legacy_causal_links(memory, request_context):
"""Legacy causal edges survive export/import without becoming retain inputs."""
src = _unique_bank("transfer_legacy_causal_src")
@@ -1123,6 +1131,7 @@ async def test_transfer_preserves_legacy_causal_links(memory, request_context):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_export_import_observations(memory, request_context):
"""With include_observations, observations transfer and their sources re-link."""
src = _unique_bank("transfer_obs_src")
@@ -1203,6 +1212,7 @@ async def test_export_import_observations(memory, request_context):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_import_triggers_consolidation(memory, request_context):
"""Importing (without observations) triggers consolidation in the target bank,
so observations get generated there same as a normal retain."""
@@ -1366,6 +1376,7 @@ async def test_import_on_conflict_modes(memory, request_context):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_http_export_import_endpoints(api_client, memory, request_context):
"""Round trip through the async HTTP export (POST + poll + download) and import endpoints."""
src = _unique_bank("transfer_http_src")
@@ -1547,6 +1558,7 @@ async def test_import_rejects_invalid_on_conflict(memory, request_context):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_bank_import_classifies_label_entities(memory, request_context):
"""An imported bank's label entities are stored with entity_kind='label'.
@@ -1624,6 +1636,7 @@ async def test_bank_import_classifies_label_entities(memory, request_context):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_async_export_roundtrip(memory, request_context):
"""The async export operation stashes a real archive that re-imports cleanly.
@@ -1669,6 +1682,7 @@ async def test_async_export_include_observations_subset_rejected(memory, request
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_export_attach_batching_preserves_entities_and_causal_links(memory, request_context, monkeypatch):
"""Batched attach queries carry every fact's entities and cross-batch causal edges.
+16 -52
View File
@@ -1916,7 +1916,6 @@ async def test_retain_multivalue_tag_entities_all_stored(memory_real_llm, reques
The original bug: tags are added correctly, but unit_entities only stores
a subset (typically the first entity).
"""
from hindsight_api.engine.memory_engine import fq_table
bank_id = f"test-1558-multivalue-tag-{uuid.uuid4().hex[:8]}"
try:
@@ -1964,32 +1963,14 @@ async def test_retain_multivalue_tag_entities_all_stored(memory_real_llm, reques
assert len(unit_ids) > 0, "Should have extracted at least one fact"
async with memory_real_llm._pool.acquire() as conn:
# Check entities in unit_entities table
entity_rows = await conn.fetch(
f"""
SELECT e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON e.id = ue.entity_id
WHERE ue.unit_id = ANY($1::uuid[])
""",
[u for u in unit_ids],
)
entity_names = {r["canonical_name"].lower() for r in entity_rows}
# Check tags on memory_units
tag_rows = await conn.fetch(
f"""
SELECT id, tags
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
""",
[u for u in unit_ids],
)
all_tags = set()
for row in tag_rows:
if row["tags"]:
all_tags.update(t.lower() for t in row["tags"])
# Entities and tags both come back on the unit itself, so one read per
# unit covers what the two joins used to.
entity_names: set[str] = set()
all_tags: set[str] = set()
for unit_id in unit_ids:
unit = await memory_real_llm.get_memory_unit(bank_id, str(unit_id), request_context)
entity_names.update(name.lower() for name in unit["entities"])
all_tags.update(tag.lower() for tag in unit["tags"])
# Filter to use:* entities/tags
use_entities = {n for n in entity_names if n.startswith("use:")}
@@ -2025,7 +2006,6 @@ async def test_retain_multivalue_tag_entities_second_retain(memory_real_llm, req
temporal proximity could exceed the 0.6 merge threshold, causing both to
resolve to the same entity ID.
"""
from hindsight_api.engine.memory_engine import fq_table
bank_id = f"test-1558-second-{uuid.uuid4().hex[:8]}"
try:
@@ -2075,30 +2055,14 @@ async def test_retain_multivalue_tag_entities_second_retain(memory_real_llm, req
assert len(unit_ids_2) > 0
async with memory_real_llm._pool.acquire() as conn:
entity_rows = await conn.fetch(
f"""
SELECT e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON e.id = ue.entity_id
WHERE ue.unit_id = ANY($1::uuid[])
""",
[u for u in unit_ids_2],
)
entity_names = {r["canonical_name"].lower() for r in entity_rows}
tag_rows = await conn.fetch(
f"""
SELECT id, tags
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
""",
[u for u in unit_ids_2],
)
all_tags = set()
for row in tag_rows:
if row["tags"]:
all_tags.update(t.lower() for t in row["tags"])
# Entities and tags both come back on the unit itself, so one read per
# unit covers what the two joins used to.
entity_names: set[str] = set()
all_tags: set[str] = set()
for unit_id in unit_ids_2:
unit = await memory_real_llm.get_memory_unit(bank_id, str(unit_id), request_context)
entity_names.update(name.lower() for name in unit["entities"])
all_tags.update(tag.lower() for tag in unit["tags"])
use_entities = {n for n in entity_names if n.startswith("use:")}
use_tags = {t for t in all_tags if t.startswith("use:")}
@@ -173,3 +173,26 @@ def test_intake_attaches_fact_dates_after_dropping():
# candidate must not shift it.
assert _texts(all_entities_flat) == ["Alice"]
assert all_entities_flat[0]["event_date"] == when
def test_intake_keeps_the_stricter_resolve_flag_when_normalization_collapses_names():
"""A caller's literal name must not become resolvable via a normalization collision (#3479).
entity_processing dedups caller-supplied against extracted names on the RAW text, so
"Acme Corp" and "Acme\nCorp" both arrive here and only collapse after normalization.
Keeping the first entry verbatim would drop the caller's resolve=False with it.
"""
all_entities_flat, _all, _map = _prepare(
[
{"text": "Acme\nCorp", "type": "ORG"}, # extracted: resolvable
{"text": "Acme Corp", "type": "ORG", "resolve": False}, # caller: literal
]
)
assert _texts(all_entities_flat) == ["Acme Corp"], "still one mention after normalization"
assert all_entities_flat[0]["resolve"] is False, "the caller's literal intent survives the merge"
def test_intake_defaults_resolve_to_true():
all_entities_flat, _all, _map = _prepare([{"text": "Alice", "type": "PERSON"}, _FakeEntity("Bob")])
assert [e["resolve"] for e in all_entities_flat] == [True, True]
@@ -250,6 +250,127 @@ async def test_fuzzy_scoring_never_merges_regular_text_into_label_row():
ops.bulk_insert_entities.assert_awaited_once()
@pytest.mark.asyncio
async def test_unresolved_names_beat_a_winning_fuzzy_candidate():
"""A caller-authored name must not be re-resolved to a similar entity (#3479).
"Dr Wall" here is the shape that broke curation: a typo entity that outscores the
0.6 threshold on name similarity (0.41) plus co-occurrence with the other name in
the same request (0.3), so resolution silently swaps it in for the corrected
"Dr. Waller". With resolve=False on that mention the scoring is skipped entirely and
the literal name is created/reused instead.
"""
from types import SimpleNamespace
now = datetime.now(timezone.utc)
candidates = {"Dr. Waller": [("typo-entity-id", "Dr Wall", {}, now, 40)]}
cooccurrence = {"typo-entity-id": {"careorg"}}
entities_data = [
{
"text": "Dr. Waller",
"nearby_entities": [{"text": "Dr. Waller"}, {"text": "CareOrg"}],
"event_date": now,
}
]
# Fuzzy matching (retain's behaviour) picks the typo entity — the bug, pinned here so
# the assertion below is measuring a real difference.
fuzzy_resolver = EntityResolver(
pool=SimpleNamespace(
ops=SimpleNamespace(bulk_insert_entities=AsyncMock(), fetch_missing_entity_ids=AsyncMock())
),
entity_lookup="full",
)
fuzzy = await fuzzy_resolver._resolve_from_candidates(
conn=AsyncMock(),
bank_id="bank-1",
entities_data=entities_data,
unit_event_date=now,
all_candidates=candidates,
cooccurrence_map=cooccurrence,
)
assert fuzzy[0].canonical_name == "Dr Wall", "precondition: fuzzy scoring picks the typo entity"
ops = SimpleNamespace(
bulk_insert_entities=AsyncMock(return_value={"dr. waller": "correct-entity-id"}),
fetch_missing_entity_ids=AsyncMock(return_value=[]),
)
exact_resolver = EntityResolver(pool=SimpleNamespace(ops=ops), entity_lookup="full")
exact = await exact_resolver._resolve_from_candidates(
conn=AsyncMock(),
bank_id="bank-1",
entities_data=[{**entities_data[0], "resolve": False}],
unit_event_date=now,
all_candidates=candidates,
cooccurrence_map=cooccurrence,
)
assert exact == [ResolvedEntity(entity_id="correct-entity-id", canonical_name="Dr. Waller")]
@pytest.mark.asyncio
async def test_per_mention_resolve_flag_only_spares_the_names_that_opt_out():
"""Retain mixes caller-supplied and extracted names in one batch (#3479).
The flag is per mention precisely so a caller can have their own names taken literally
without turning off resolution for the extractor's — which would silently fill the bank
with near-duplicate entities. Here the caller's "Dr. Waller" must be created as written
while the extractor's "Dr Waler" still resolves onto the existing "Dr Wall".
"""
from types import SimpleNamespace
now = datetime.now(timezone.utc)
typo = ("typo-entity-id", "Dr Wall", {}, now, 40)
ops = SimpleNamespace(
bulk_insert_entities=AsyncMock(return_value={"dr. waller": "literal-id"}),
fetch_missing_entity_ids=AsyncMock(return_value=[]),
)
resolver = EntityResolver(pool=SimpleNamespace(ops=ops), entity_lookup="full")
resolved = await resolver._resolve_from_candidates(
conn=AsyncMock(),
bank_id="bank-1",
entities_data=[
{"text": "Dr. Waller", "nearby_entities": [], "event_date": now, "resolve": False},
{"text": "Dr Waler", "nearby_entities": [], "event_date": now},
],
unit_event_date=now,
all_candidates={"Dr. Waller": [typo], "Dr Waler": [typo]},
cooccurrence_map={},
)
assert resolved[0] == ResolvedEntity(entity_id="literal-id", canonical_name="Dr. Waller"), (
"the opted-out name is created as written"
)
assert resolved[1].canonical_name == "Dr Wall", "the extracted name still resolves onto the existing entity"
@pytest.mark.asyncio
async def test_intrabatch_clustering_skips_names_that_opted_out():
"""A literal name must not be folded into a same-batch variant, or pull one into itself."""
from types import SimpleNamespace
ops = SimpleNamespace(
bulk_insert_entities=AsyncMock(return_value={"alice": "alice-id", "alice smith": "alice-smith-id"}),
fetch_missing_entity_ids=AsyncMock(return_value=[]),
)
resolver = EntityResolver(pool=SimpleNamespace(ops=ops), entity_lookup="full")
resolved = await resolver._resolve_from_candidates(
conn=AsyncMock(),
bank_id="bank-1",
entities_data=[
{"text": "Alice", "nearby_entities": [], "resolve": False},
{"text": "Alice Smith", "nearby_entities": [], "resolve": False},
],
unit_event_date=None,
all_candidates={},
cooccurrence_map={},
)
assert [e.canonical_name for e in resolved] == ["Alice", "Alice Smith"]
assert [e.entity_id for e in resolved] == ["alice-id", "alice-smith-id"]
# ---------------------------------------------------------------------------
# Oracle fuzzy entity resolution — unit tests (mock conn, no live DB)
# ---------------------------------------------------------------------------
@@ -685,6 +685,7 @@ async def test_converter_registry():
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_file_conversion_creates_separate_retain_operation(memory_no_llm_verify, sample_txt_content):
"""Test that file conversion and retain are two separate async operations.
@@ -150,6 +150,7 @@ async def test_graph_q_and_tags_filter_combined(api_client, test_bank_id):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_graph_document_filter_includes_observations_via_source_memories(
memory, api_client, test_bank_id, request_context
):
@@ -297,6 +298,7 @@ async def _seed_scoped_observations(memory, bank_id, request_context):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_observation_scopes_enumeration(memory, api_client, test_bank_id, request_context):
"""The scopes endpoint enumerates distinct tag sets (order-normalized) with counts."""
await _seed_scoped_observations(memory, test_bank_id, request_context)
@@ -313,6 +315,7 @@ async def test_observation_scopes_enumeration(memory, api_client, test_bank_id,
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_graph_exact_scope_filter(memory, api_client, test_bank_id, request_context):
"""tags_match=exact filters observations to exactly one scope, not supersets."""
await _seed_scoped_observations(memory, test_bank_id, request_context)
@@ -337,6 +340,7 @@ async def test_graph_exact_scope_filter(memory, api_client, test_bank_id, reques
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_graph_exact_global_scope_filter(memory, api_client, test_bank_id, request_context):
"""tags_match=exact with no tags is the global scope: untagged observations only."""
await _seed_scoped_observations(memory, test_bank_id, request_context)
@@ -27,6 +27,11 @@ from hindsight_api.engine.graph_maintenance import (
)
from hindsight_api.engine.memory_engine import MemoryEngine
# Every test here seeds memory_units / memory_links / entities with raw INSERTs and
# asserts raw link-row counts, as the module docstring says — none of it round-trips
# through the store, so a backend that keeps those rows outside SQL sees an empty graph.
pytestmark = pytest.mark.memory_backend_incompatible
async def _ensure_bank(memory: MemoryEngine, bank_id: str, request_context: RequestContext) -> None:
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
@@ -196,6 +196,7 @@ async def test_unordered_concurrent_sweep_and_upsert_deadlocks(memory: MemoryEng
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_graph_maintenance_sweep_retries_on_deadlock(memory: MemoryEngine, request_context: RequestContext):
"""The entity-prune batch must survive a deadlock.
@@ -38,8 +38,7 @@ class MockTenantExtension(TenantExtension):
class _FakeBankOps:
async def create_bank_vector_indexes(self, *args, **kwargs):
return None
"""Dialect ops stub. Bank creation issues no index DDL (#3485), so this is bare."""
class FakeBankConfigBackend:
+114 -17
View File
@@ -3,8 +3,14 @@ Tests for per-bank vector index lifecycle and UNION ALL retrieval.
Covers:
- _bank_index_name deterministic naming
- Per-bank vector indexes created on bank creation (retain_async / ensure_bank_exists)
- Per-bank vector indexes dropped on bank deletion
- At the default threshold (0 = no minimum), a retained bank still ends up with
its three per-(bank, fact_type) indexes the pre-#3485 outcome — but they are
built by the queued vector_index_maintenance operation rather than inline on
the request path
- An untouched bank gets none: bank creation issues no index DDL, and an index
over an empty partition serves nothing
- Per-bank vector indexes dropped on bank deletion, the one request path that
still issues vector-index DDL
- retrieve_semantic_bm25_combined_sql groups results correctly by fact_type and source
"""
@@ -13,7 +19,26 @@ from datetime import datetime, timezone
import pytest
from hindsight_api.engine.retain.bank_utils import _BANK_INDEX_FACT_TYPES, _bank_index_name
from hindsight_api.engine import vector_index_health
from hindsight_api.engine.db_utils import retry_with_backoff
from hindsight_api.engine.retain.bank_utils import (
_BANK_INDEX_FACT_TYPES,
_bank_index_name,
_vector_index_clause,
)
@pytest.fixture
def default_threshold(monkeypatch):
"""Restore the shipped default (0 = no minimum) for this test.
conftest raises the threshold out of reach suite-wide so thousands of
throwaway banks don't each queue an index build; asserting the default
behaviour means putting it back.
"""
monkeypatch.setattr(vector_index_health, "qualifies_for_per_bank_index", lambda rows: rows > 0)
monkeypatch.setattr(vector_index_health, "should_keep_per_bank_index", lambda rows: rows > 0)
# ---------------------------------------------------------------------------
# Unit tests — no DB required
@@ -78,9 +103,46 @@ async def _get_bank_vector_indexes(pool, bank_id: str) -> list[str]:
return [row["indexname"] for row in rows]
async def _build_bank_vector_indexes(pool, bank_id: str) -> list[str]:
"""Give a bank its three partial indexes, as the maintenance sweep would.
Used by the delete-path test: retain no longer creates them, so a bank has
to be given them before deletion can be asked to take them away.
"""
index_clause = _vector_index_clause()
assert index_clause is not None
async with pool.acquire() as conn:
internal_id = str(await conn.fetchval("SELECT internal_id FROM banks WHERE bank_id = $1", bank_id))
literal = await conn.fetchval("SELECT quote_literal($1::text)", bank_id)
names = []
for ft in _BANK_INDEX_FACT_TYPES:
name = _bank_index_name(ft, internal_id)
# CONCURRENTLY, and retried: a plain CREATE INDEX takes ShareLock on
# the shared memory_units table, which forms a deadlock cycle with
# another xdist worker's DROP INDEX CONCURRENTLY (ShareUpdateExclusive)
# — observed as a three-way cycle in CI.
await retry_with_backoff(
lambda name=name, ft=ft: conn.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {name} ON memory_units {index_clause} "
f"WHERE fact_type = '{ft}' AND bank_id = {literal}"
)
)
names.append(name)
return names
@pytest.mark.asyncio
async def test_retain_creates_per_bank_vector_indexes(memory, request_context):
"""retain_async on a new bank must create 3 per-(bank, fact_type) vector indexes."""
async def test_retain_still_ends_up_with_per_bank_indexes(memory, request_context, default_threshold):
"""At the shipped default a retained bank has the same coverage it always had.
The threshold defaults to 0 no minimum so every partition holding rows is
indexed, exactly as before #3485. What changed is *who* builds them: the
index DDL used to run inside the retain transaction, taking a ShareLock on
the shared memory_units table that deadlocked against concurrent writers.
Now retain queues a vector_index_maintenance operation and returns; the tests
run a synchronous task backend, so the operation has completed by the time
retain_async does.
"""
bank_id = f"test_hnsw_create_{uuid.uuid4().hex[:8]}"
try:
await memory.retain_async(
@@ -89,18 +151,39 @@ async def test_retain_creates_per_bank_vector_indexes(memory, request_context):
request_context=request_context,
)
indexes = await _get_bank_vector_indexes(memory._pool, bank_id)
assert len(indexes) == 3, f"Expected 3 per-bank vector indexes, got: {indexes}"
for ft_short in _BANK_INDEX_FACT_TYPES.values():
assert any(ft_short in idx for idx in indexes), (
f"Missing index for fact_type short '{ft_short}' in {indexes}"
)
assert indexes, "a retained bank should end up with per-bank vector indexes at the default threshold"
for name in indexes:
assert "_" in name
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_bank_creation_alone_creates_no_vector_indexes(memory, request_context):
"""Creating a bank must issue no index DDL — it is the request path that hurt.
A fresh bank holds no rows, so its three indexes would cover nothing while
still being locked and planned against by every other bank's queries. This is
the difference that makes bank count stop being a ceiling (#3485).
"""
bank_id = f"test_hnsw_empty_{uuid.uuid4().hex[:8]}"
try:
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
indexes = await _get_bank_vector_indexes(memory._pool, bank_id)
assert indexes == [], f"bank creation must not create vector indexes, got: {indexes}"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delete_bank_drops_vector_indexes(memory, request_context):
"""delete_bank must drop all per-bank vector indexes."""
"""delete_bank must drop the per-bank vector indexes a large bank had.
Still the one request path that issues vector-index DDL: an index outliving
its bank would be charged to every surviving bank's query planning forever,
and nothing else knows the internal_id it is named after.
"""
bank_id = f"test_hnsw_drop_{uuid.uuid4().hex[:8]}"
await memory.retain_async(
@@ -108,9 +191,9 @@ async def test_delete_bank_drops_vector_indexes(memory, request_context):
content="Bob is a data scientist.",
request_context=request_context,
)
# Verify indexes exist before deletion
await _build_bank_vector_indexes(memory._pool, bank_id)
indexes_before = await _get_bank_vector_indexes(memory._pool, bank_id)
assert len(indexes_before) == 3
assert len(indexes_before) == 3, "setup: the bank should have indexes to drop"
await memory.delete_bank(bank_id, request_context=request_context)
@@ -119,8 +202,12 @@ async def test_delete_bank_drops_vector_indexes(memory, request_context):
@pytest.mark.asyncio
async def test_retain_idempotent_bank_creation(memory, request_context):
"""Retaining into the same bank twice must not error and still have exactly 3 indexes."""
async def test_retain_idempotent_bank_creation(memory, request_context, default_threshold):
"""Retaining twice must not error, and must not duplicate or rebuild indexes.
The second retain queues another maintenance operation; its plan has to come
back empty so a busy bank is not rebuilding ANN indexes on every write.
"""
bank_id = f"test_hnsw_idem_{uuid.uuid4().hex[:8]}"
try:
await memory.retain_async(
@@ -128,13 +215,17 @@ async def test_retain_idempotent_bank_creation(memory, request_context):
content="Carol is a product manager.",
request_context=request_context,
)
after_first = await _get_bank_vector_indexes(memory._pool, bank_id)
await memory.retain_async(
bank_id=bank_id,
content="Carol joined the company in 2022.",
request_context=request_context,
)
indexes = await _get_bank_vector_indexes(memory._pool, bank_id)
assert len(indexes) == 3
assert await _get_bank_vector_indexes(memory._pool, bank_id) == after_first
submitted = await memory.submit_async_vector_index_maintenance(bank_id=bank_id, request_context=request_context)
assert submitted["no_work"] is True, "a settled bank must stop queueing maintenance"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -187,6 +278,7 @@ async def test_retrieve_semantic_bm25_grouped_by_fact_type(memory, request_conte
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_fetch_unit_dates_ignores_noncanonical_uuid_inputs(memory, request_context):
"""The indexed UUID lookup preserves the old text-comparison input behavior."""
from hindsight_api.engine.db.ops_postgresql import PostgreSQLOps
@@ -245,6 +337,11 @@ async def test_recall_reuses_semantic_pool_for_graph_seeds(memory, request_conte
@pytest.mark.asyncio
# Asserts *how* the graph arm seeds — that recall calls link_expansion_retrieval's
# _find_semantic_seeds — rather than what it returns. A store with its own graph
# retrieval never goes through that function, so the assertion is specific to the
# SQL retrieval path.
@pytest.mark.memory_backend_incompatible
async def test_recall_keeps_graph_seed_query_for_stricter_semantic_floor(memory, request_context, monkeypatch):
"""A semantic floor above the graph floor must retain the dedicated seed query."""
from hindsight_api.engine.response_models import MinScores
@@ -101,7 +101,7 @@ async def test_full_api_workflow(api_client, test_bank_id):
# ================================================================
# List banks (should be empty initially or have other test banks)
response = await api_client.get("/v1/default/banks")
response = await api_client.get("/v1/default/banks", params={"limit": 1000})
assert response.status_code == 200
initial_banks_data = response.json()["banks"]
initial_banks = [a["bank_id"] for a in initial_banks_data]
@@ -211,7 +211,7 @@ async def test_full_api_workflow(api_client, test_bank_id):
assert fresh_stats["total_nodes"] == stats["total_nodes"]
# Verify bank list returns stats (fact_count, last_document_at)
response = await api_client.get("/v1/default/banks")
response = await api_client.get("/v1/default/banks", params={"limit": 1000})
assert response.status_code == 200
banks_after = response.json()["banks"]
our_bank = next(b for b in banks_after if b["bank_id"] == test_bank_id)
@@ -354,7 +354,7 @@ async def test_full_api_workflow(api_client, test_bank_id):
# 9. List All Banks (should include our test bank)
# ================================================================
response = await api_client.get("/v1/default/banks")
response = await api_client.get("/v1/default/banks", params={"limit": 1000})
assert response.status_code == 200
final_banks_data = response.json()["banks"]
final_banks = [a["bank_id"] for a in final_banks_data]
@@ -552,6 +552,7 @@ async def test_document_deletion_with_slashes_in_id(api_client):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_delete_bank(api_client):
"""Test delete bank endpoint.
@@ -601,7 +602,7 @@ async def test_delete_bank(api_client):
assert len(response.json()["items"]) > 0
# Check bank is in list
response = await api_client.get("/v1/default/banks")
response = await api_client.get("/v1/default/banks", params={"limit": 1000})
assert response.status_code == 200
bank_ids = [b["bank_id"] for b in response.json()["banks"]]
assert test_bank_id in bank_ids
@@ -616,7 +617,7 @@ async def test_delete_bank(api_client):
# 4. Verify bank and all data is deleted
# Bank should not be in list
response = await api_client.get("/v1/default/banks")
response = await api_client.get("/v1/default/banks", params={"limit": 1000})
assert response.status_code == 200
bank_ids = [b["bank_id"] for b in response.json()["banks"]]
assert test_bank_id not in bank_ids
@@ -674,7 +675,7 @@ async def test_clear_memories_preserves_bank(api_client):
assert response.status_code == 200
assert response.json()["total_nodes"] > 0
response = await api_client.get("/v1/default/banks")
response = await api_client.get("/v1/default/banks", params={"limit": 1000})
assert response.status_code == 200
bank_ids = [b["bank_id"] for b in response.json()["banks"]]
assert test_bank_id in bank_ids
@@ -685,7 +686,7 @@ async def test_clear_memories_preserves_bank(api_client):
assert response.json()["success"] is True
# 3. Bank should still exist in the list
response = await api_client.get("/v1/default/banks")
response = await api_client.get("/v1/default/banks", params={"limit": 1000})
assert response.status_code == 200
bank_ids = [b["bank_id"] for b in response.json()["banks"]]
assert test_bank_id in bank_ids, "Bank should still exist after clearing memories"
@@ -2106,7 +2107,7 @@ async def test_patch_bank_does_not_create_missing_bank(api_client, memory, monke
profile = await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
assert profile.status_code == 404, profile.text
banks = await api_client.get("/v1/default/banks")
banks = await api_client.get("/v1/default/banks", params={"limit": 1000})
assert banks.status_code == 200, banks.text
assert test_bank_id not in {bank["bank_id"] for bank in banks.json()["banks"]}
@@ -227,6 +227,7 @@ class TestTree:
assert loose["trigger"]["refresh_after_consolidation"] is False
assert loose["trigger"]["mode"] == "delta" # untouched by the patch
@pytest.mark.memory_backend_incompatible
async def test_tree_staleness_follows_the_bank_watermark(self, api_client, memory, kb_bank):
"""The tree answers from one bank-wide watermark, not a scan per page.
@@ -151,6 +151,7 @@ async def test_repeated_large_same_id_replacement_is_idempotent(memory, request_
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_append_after_zero_fact_header_slice_skips_unchanged_history(
memory,
request_context,
@@ -444,6 +444,35 @@ class TestComputeSemanticLinksAnnPgBouncerSafety:
# And there must not be a RESET — SET LOCAL handles it at commit.
assert not any(f"RESET {guc}" in s for s in executed_sql)
@pytest.mark.asyncio
async def test_skips_a_guc_the_server_has_rejected(self, mock_conn, monkeypatch):
"""An unknown GUC must not be attempted inside this transaction.
hnsw.iterative_scan needs pgvector 0.8+, and pgvector reserves the "hnsw."
prefix, so an older server errors on it rather than accepting a placeholder
and an error inside an open transaction aborts the whole link computation, not
just the setting. The pool's session setup names the same GUCs on acquire, so
by the time this runs an unknown one is already recorded.
"""
from hindsight_api.engine.db import postgresql as pg_backend
monkeypatch.setenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector")
monkeypatch.setattr(pg_backend, "_unsupported_settings", {"hnsw.iterative_scan"})
await compute_semantic_links_ann(
conn=mock_conn,
bank_id="bank-1",
unit_ids=["u1"],
embeddings=[[0.1] * 384],
fact_types=["world"],
threshold=DEFAULT_SEMANTIC_LINK_MIN_SIMILARITY,
)
executed_sql = [call.args[0] for call in mock_conn.execute.call_args_list]
assert not any("hnsw.iterative_scan" in s for s in executed_sql)
# The supported one is still applied.
assert any("hnsw.ef_search" in s for s in executed_sql)
@pytest.mark.asyncio
async def test_vchord_ann_does_not_set_fixed_probe_count(self, mock_conn, monkeypatch):
"""VectorChord probe counts must come from index/default config.
@@ -55,8 +55,8 @@ async def test_list_banks_overlays_config_disposition_and_mission(memory):
assert profile["disposition"] == {"skepticism": 4, "literalism": 5, "empathy": 2}
# The list path must agree with the get path for this bank.
banks = await memory.list_banks(request_context=request_context)
entry = next((b for b in banks if b["bank_id"] == bank_id), None)
page = await memory.list_banks(search_query=bank_id, request_context=request_context)
entry = next((b for b in page["banks"] if b["bank_id"] == bank_id), None)
assert entry is not None, f"bank {bank_id!r} not present in list_banks output"
assert entry["mission"] == profile["mission"], (
@@ -57,9 +57,9 @@ async def test_list_banks_counts_via_store_for_non_sql_bank(memory, monkeypatch)
await memory.get_bank_profile(bank_id, request_context=request_context)
# Must not raise NameError; must reach the store's non-SQL count path.
banks = await memory.list_banks(request_context=request_context)
page = await memory.list_banks(search_query=bank_id, request_context=request_context)
entry = next((b for b in banks if b["bank_id"] == bank_id), None)
entry = next((b for b in page["banks"] if b["bank_id"] == bank_id), None)
assert entry is not None, f"bank {bank_id!r} not present in list_banks output"
# The capability + count were consulted with the row's real bank id.
@@ -0,0 +1,113 @@
"""Tests for pagination and search on the bank list.
``GET /v1/default/banks`` used to return every bank in the system in one
response: no limit, no offset, and a SQL query with no LIMIT clause. On an
instance with many banks that is an unbounded payload, plus per-bank config
resolution (and a live store count for non-SQL stores) for every bank rather
than the ones actually being shown.
Runs via: uv run pytest tests/test_list_banks_pagination.py -v
"""
from __future__ import annotations
import uuid
import pytest
@pytest.fixture
async def three_banks(memory, request_context):
"""Three banks sharing a unique prefix, so the search is xdist-safe."""
prefix = f"pagebank{uuid.uuid4().hex[:8]}"
bank_ids = [f"{prefix}_{i}" for i in range(3)]
for bank_id in bank_ids:
await memory.get_bank_profile(bank_id, request_context=request_context)
try:
yield prefix, bank_ids
finally:
for bank_id in bank_ids:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_pages_are_disjoint_and_cover_every_match(memory, request_context, three_banks):
prefix, bank_ids = three_banks
first = await memory.list_banks(search_query=prefix, limit=2, offset=0, request_context=request_context)
second = await memory.list_banks(search_query=prefix, limit=2, offset=2, request_context=request_context)
assert first["total"] == 3
assert first["limit"] == 2
assert first["offset"] == 0
assert len(first["banks"]) == 2
assert second["total"] == 3
assert second["offset"] == 2
assert len(second["banks"]) == 1
paged = [bank["bank_id"] for bank in first["banks"] + second["banks"]]
assert len(set(paged)) == 3, f"pages overlap: {paged}"
assert set(paged) == set(bank_ids)
@pytest.mark.asyncio
async def test_offset_past_the_end_returns_no_banks_but_the_real_total(memory, request_context, three_banks):
prefix, _ = three_banks
page = await memory.list_banks(search_query=prefix, limit=10, offset=3, request_context=request_context)
assert page["banks"] == []
assert page["total"] == 3
@pytest.mark.asyncio
async def test_limit_zero_returns_no_banks(memory, request_context, three_banks):
prefix, _ = three_banks
page = await memory.list_banks(search_query=prefix, limit=0, request_context=request_context)
assert page["banks"] == []
assert page["total"] == 3
@pytest.mark.asyncio
async def test_negative_paging_values_are_clamped(memory, request_context, three_banks):
"""The MCP tool takes limit/offset straight from a model, and the page is a Python
slice a negative value must not silently trim the tail."""
prefix, _ = three_banks
page = await memory.list_banks(search_query=prefix, limit=-1, offset=-5, request_context=request_context)
assert page["banks"] == []
assert page["total"] == 3
@pytest.mark.asyncio
async def test_search_matches_bank_name_case_insensitively(memory, request_context):
bank_id = f"searchname{uuid.uuid4().hex[:8]}"
display_name = f"Zeta {uuid.uuid4().hex[:8]}"
try:
await memory.get_bank_profile(bank_id, request_context=request_context)
await memory.update_bank(bank_id, name=display_name, request_context=request_context)
page = await memory.list_banks(search_query=display_name.upper(), request_context=request_context)
assert [bank["bank_id"] for bank in page["banks"]] == [bank_id]
assert page["total"] == 1
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_http_endpoint_echoes_paging_and_filters(api_client, three_banks):
prefix, _ = three_banks
response = await api_client.get("/v1/default/banks", params={"q": prefix, "limit": 1, "offset": 1})
assert response.status_code == 200, response.text
body = response.json()
assert body["total"] == 3
assert body["limit"] == 1
assert body["offset"] == 1
assert len(body["banks"]) == 1
assert body["banks"][0]["bank_id"].startswith(prefix)
@@ -53,6 +53,7 @@ def _ids(result: dict) -> set[str]:
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_created_before_filter(memory: MemoryEngine, request_context: RequestContext):
bank_id = f"test-lmu-created-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
@@ -0,0 +1,105 @@
"""What ``list_memory_units`` / ``list_entities`` put in each item.
Tests that need a unit's write watermark, its lineage, or an entity's kind used to
read the columns straight out of ``memory_units`` / ``entities``. Those are part of
the read model, so they are on the item and asserted here through the engine, on
units written by retain rather than seeded with SQL, so the coverage holds for any
store behind the memories seam.
"""
import uuid
from datetime import datetime
import pytest
from hindsight_api import RequestContext
from hindsight_api.engine.memory_engine import MemoryEngine
async def _retain(memory: MemoryEngine, bank_id: str, content: str, request_context: RequestContext) -> list[str]:
return await memory.retain_async(bank_id=bank_id, content=content, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_fact_type_accepts_a_list(memory: MemoryEngine, request_context: RequestContext):
"""A list of fact types matches any of them — the source-fact selection callers want."""
bank_id = f"test-lmu-facttypes-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
try:
await _retain(memory, bank_id, "Alice deployed the release on Friday.", request_context)
async def listed(fact_type):
page = await memory.list_memory_units(
bank_id, fact_type=fact_type, limit=500, request_context=request_context
)
return {item["id"] for item in page["items"]}, page["total"]
world_ids, world_total = await listed("world")
exp_ids, exp_total = await listed("experience")
both_ids, both_total = await listed(["world", "experience"])
# The list arm is exactly the union of the single-value arms, so the
# assertion holds however the LLM happened to classify the facts.
assert both_ids == world_ids | exp_ids
assert both_total == world_total + exp_total
# An empty list filters nothing, matching the "omitted" case.
_, empty_total = await listed([])
_, unfiltered_total = await listed(None)
assert empty_total == unfiltered_total
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_items_carry_updated_at_and_lineage(memory: MemoryEngine, request_context: RequestContext):
"""Each item carries its write watermark and (for observations) its sources."""
bank_id = f"test-lmu-readmodel-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
try:
await _retain(memory, bank_id, "Bob moved to Berlin in March.", request_context)
page = await memory.list_memory_units(bank_id, limit=500, request_context=request_context)
assert page["items"], "retain must have produced at least one fact"
source_ids = {item["id"] for item in page["items"] if item["fact_type"] != "observation"}
assert source_ids, "retain must have produced at least one source fact"
for item in page["items"]:
# Parseable rather than merely present: callers do date arithmetic on it.
assert isinstance(datetime.fromisoformat(item["updated_at"]), datetime)
if item["fact_type"] == "observation":
# An observation's lineage points at the facts it was drawn from,
# and those facts are in this same bank.
assert item["source_memory_ids"], "an observation must carry its sources"
assert set(item["source_memory_ids"]) <= source_ids
else:
# A source fact has no lineage; the field is always there, never absent.
assert item["source_memory_ids"] == []
# The list item and the detail view agree on the unit.
first = page["items"][0]
detail = await memory.get_memory_unit(bank_id, first["id"], request_context)
assert detail["text"] == first["text"]
if first["fact_type"] == "observation":
assert detail["source_memory_ids"] == first["source_memory_ids"]
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_entities_carry_their_kind(memory: MemoryEngine, request_context: RequestContext):
"""list_entities reports how each entity was classified."""
bank_id = f"test-entities-kind-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
try:
await _retain(memory, bank_id, "Carol works with Dave at Acme.", request_context)
page = await memory.list_entities(bank_id, limit=500, request_context=request_context)
assert page["items"], "retain must have produced at least one entity"
for item in page["items"]:
assert "entity_kind" in item, "entity_kind is part of the entity read model"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -360,6 +360,26 @@ async def test_middleware_handles_both_endpoints(mock_memory):
assert "create_bank" not in single_bank_tools
def test_registered_tool_sets_match_the_allowlists(mock_memory):
"""Every allowlisted tool name must resolve to a really-registered tool.
Three hand-maintained lists have to agree: ``_ALL_TOOLS``, the default set in
``register_mcp_tools()``, and the single-bank allowlist in
``create_mcp_server()``. A name added to one but not the others is silent
the tool simply never appears on the endpoint and nothing fails so assert
over the whole sets rather than spot-checking tool names.
"""
from hindsight_api.api.mcp import create_mcp_server
from hindsight_api.mcp_tools import _ALL_TOOLS
multi = set(_tools(create_mcp_server(mock_memory, multi_bank=True)))
single = set(_tools(create_mcp_server(mock_memory, multi_bank=False)))
assert multi == set(_ALL_TOOLS)
# Single-bank mode drops exactly the tools that operate across banks.
assert multi - single == {"list_banks", "create_bank", "get_bank_stats"}
def test_global_mcp_enabled_tools_filter_restricts_registered_tools(mock_memory):
"""Test that global mcp_enabled_tools env setting restricts which tools are registered."""
from unittest.mock import MagicMock, patch
+288 -1
View File
@@ -9,7 +9,9 @@ import pytest
from hindsight_api.engine.memory_engine import DirectivePage, MentalModelPage
from hindsight_api.mcp_tools import (
KNOWLEDGE_ROOT_PARENT,
MCPToolsConfig,
_knowledge_tree_json,
_validate_mental_model_inputs,
build_content_dict,
parse_timestamp,
@@ -127,6 +129,43 @@ def _apply_detail(model: dict, detail: str) -> dict:
return model
# Knowledge-base fixtures: a root folder with one page under it, shaped like the
# dicts MemoryEngine._row_to_knowledge_node returns.
_KNOWLEDGE_FOLDER: dict[str, Any] = {
"id": "kf-1",
"kind": "folder",
"name": "Runbooks",
"parent_id": None,
"mental_model_id": None,
"managed": False,
"updated_at": "2026-01-01T00:00:00+00:00",
}
_KNOWLEDGE_PAGE_NODE: dict[str, Any] = {
"id": "kp-1",
"kind": "page",
"name": "Deploys",
"parent_id": "kf-1",
"mental_model_id": "mm-page",
"managed": False,
"tags": ["ops"],
"source_query": "How is the service deployed?",
"last_refreshed_at": "2026-01-02T00:00:00+00:00",
"trigger": {"refresh_after_consolidation": True},
}
_KNOWLEDGE_NODES: list[dict[str, Any]] = [
_KNOWLEDGE_FOLDER,
{**_KNOWLEDGE_PAGE_NODE, "is_stale": False},
]
_KNOWLEDGE_PAGE: dict[str, Any] = {
**_KNOWLEDGE_PAGE_NODE,
"tags": ["type:runbook", "ops"],
"content": "Run `make deploy`.",
}
@pytest.fixture
def mock_memory():
"""Create a mock MemoryEngine with all MCP tool methods."""
@@ -221,6 +260,28 @@ def mock_memory():
memory.update_bank = AsyncMock(side_effect=_update_bank)
memory.list_banks = AsyncMock(return_value=[])
# Knowledge base methods
memory.list_knowledge_nodes = AsyncMock(return_value=list(_KNOWLEDGE_NODES))
memory.get_knowledge_page = AsyncMock(return_value=dict(_KNOWLEDGE_PAGE))
memory.search_knowledge_pages = AsyncMock(
return_value=[
{
"id": "kp-1",
"name": "Deploys",
"mental_model_id": "mm-page",
"snippet": "How the service is deployed",
"score": 0.9,
"updated_at": "2026-01-02T00:00:00+00:00",
}
]
)
memory.create_knowledge_folder = AsyncMock(return_value=dict(_KNOWLEDGE_FOLDER))
memory.create_knowledge_page = AsyncMock(return_value=dict(_KNOWLEDGE_PAGE_NODE))
memory.rename_knowledge_node = AsyncMock(return_value=dict(_KNOWLEDGE_PAGE_NODE))
memory.move_knowledge_node = AsyncMock(return_value=dict(_KNOWLEDGE_PAGE_NODE))
memory.update_knowledge_page = AsyncMock(return_value=dict(_KNOWLEDGE_PAGE_NODE))
memory.delete_knowledge_node = AsyncMock(return_value=True)
return memory
@@ -387,7 +448,10 @@ class TestMentalModelToolRegistration:
assert "clear_mental_model" in tools
assert "update_memory" in tools
assert "invalidate_memory" in tools
assert len(tools) == 32
assert "get_knowledge_base_tree" in tools
assert "create_knowledge_page" in tools
assert "delete_knowledge_node" in tools
assert len(tools) == 39
def test_all_tools_have_nonempty_descriptions(self):
"""Every registered tool must expose a non-empty description.
@@ -1892,6 +1956,229 @@ class TestEmptyListReturns:
assert '"items": []' in result or "[]" in result
# =========================================================================
# Knowledge Base Tool Tests
# =========================================================================
_KB_TOOLS = {
"get_knowledge_base_tree",
"search_knowledge_base",
"get_knowledge_page",
"create_knowledge_folder",
"create_knowledge_page",
"update_knowledge_node",
"delete_knowledge_node",
}
class TestKnowledgeTreeProjection:
"""The flat node list nests into roots, with pages projected from their model."""
def test_nests_pages_under_their_folder(self):
roots = _knowledge_tree_json(_KNOWLEDGE_NODES)
assert [r["id"] for r in roots] == ["kf-1"]
assert [c["id"] for c in roots[0]["children"]] == ["kp-1"]
def test_page_carries_mental_model_metadata(self):
page = _knowledge_tree_json(_KNOWLEDGE_NODES)[0]["children"][0]
assert page["description"] == "How is the service deployed?"
assert page["tags"] == ["ops"]
assert page["timestamp"] == "2026-01-02T00:00:00+00:00"
assert page["is_stale"] is False
assert page["trigger"] == {"refresh_after_consolidation": True}
def test_orphaned_node_becomes_a_root(self):
# A node whose parent is not in the list (e.g. a partial read) must still
# surface rather than vanish from the tree.
roots = _knowledge_tree_json([{**_KNOWLEDGE_PAGE_NODE, "parent_id": "kf-missing"}])
assert [r["id"] for r in roots] == ["kp-1"]
@pytest.mark.asyncio
class TestKnowledgeBaseTools:
async def test_tools_registered(self, mock_memory):
mcp = _make_mcp_server(mock_memory, _KB_TOOLS, include_bank_id=True)
assert _KB_TOOLS == set(_tools(mcp).keys())
async def test_tools_registered_single_bank(self, mock_memory):
mcp = _make_mcp_server(mock_memory, _KB_TOOLS, include_bank_id=False)
assert _KB_TOOLS == set(_tools(mcp).keys())
async def test_get_tree(self, mock_memory):
mcp = _make_mcp_server(mock_memory, {"get_knowledge_base_tree"}, include_bank_id=True)
result = json.loads(await _tools(mcp)["get_knowledge_base_tree"].fn())
assert [r["id"] for r in result["roots"]] == ["kf-1"]
assert mock_memory.list_knowledge_nodes.call_args.kwargs["with_staleness"] is True
async def test_get_tree_single_bank(self, mock_memory):
mcp = _make_mcp_server(mock_memory, {"get_knowledge_base_tree"}, include_bank_id=False)
result = await _tools(mcp)["get_knowledge_base_tree"].fn()
assert isinstance(result, dict)
assert result["roots"][0]["kind"] == "folder"
async def test_search(self, mock_memory):
mcp = _make_mcp_server(mock_memory, {"search_knowledge_base"}, include_bank_id=True)
result = json.loads(await _tools(mcp)["search_knowledge_base"].fn(query="deploy", limit=5))
assert result["total"] == 1
assert result["results"][0]["id"] == "kp-1"
call_kwargs = mock_memory.search_knowledge_pages.call_args.kwargs
assert call_kwargs["query"] == "deploy"
assert call_kwargs["limit"] == 5
async def test_search_clamps_limit(self, mock_memory):
mcp = _make_mcp_server(mock_memory, {"search_knowledge_base"}, include_bank_id=True)
await _tools(mcp)["search_knowledge_base"].fn(query="deploy", limit=500)
assert mock_memory.search_knowledge_pages.call_args.kwargs["limit"] == 50
async def test_get_page_renders_markdown(self, mock_memory):
mcp = _make_mcp_server(mock_memory, {"get_knowledge_page"}, include_bank_id=True)
result = json.loads(await _tools(mcp)["get_knowledge_page"].fn(page_id="kp-1"))
# The `type:` tag becomes the page type and drops out of the displayed tags.
assert result["type"] == "runbook"
assert result["tags"] == ["ops"]
assert result["markdown"].startswith("---\n")
assert "Run `make deploy`." in result["markdown"]
async def test_get_page_not_found(self, mock_memory):
mock_memory.get_knowledge_page.return_value = None
mcp = _make_mcp_server(mock_memory, {"get_knowledge_page"}, include_bank_id=True)
result = await _tools(mcp)["get_knowledge_page"].fn(page_id="kp-missing")
assert "not found" in result
async def test_create_folder(self, mock_memory):
mcp = _make_mcp_server(mock_memory, {"create_knowledge_folder"}, include_bank_id=True)
result = json.loads(await _tools(mcp)["create_knowledge_folder"].fn(name="Runbooks"))
assert result["id"] == "kf-1"
assert result["children"] == []
assert mock_memory.create_knowledge_folder.call_args.kwargs["parent_id"] is None
async def test_create_folder_rejects_bad_parent(self, mock_memory):
mock_memory.create_knowledge_folder.side_effect = ValueError("Parent folder 'kf-x' not found")
mcp = _make_mcp_server(mock_memory, {"create_knowledge_folder"}, include_bank_id=True)
result = await _tools(mcp)["create_knowledge_folder"].fn(name="Runbooks", parent_id="kf-x")
assert "not found" in result
async def test_create_page_schedules_refresh(self, mock_memory):
mcp = _make_mcp_server(mock_memory, {"create_knowledge_page"}, include_bank_id=True)
result = json.loads(
await _tools(mcp)["create_knowledge_page"].fn(name="Deploys", source_query="How is it deployed?")
)
assert result["page_id"] == "kp-1"
assert result["operation_id"] == "op-123"
create_kwargs = mock_memory.create_knowledge_page.call_args.kwargs
# An unstated refresh setting must not overwrite the engine's page defaults.
assert create_kwargs["trigger"] is None
assert mock_memory.submit_async_refresh_mental_model.call_args.kwargs["mental_model_id"] == "mm-page"
async def test_create_page_with_refresh_flag(self, mock_memory):
mcp = _make_mcp_server(mock_memory, {"create_knowledge_page"}, include_bank_id=True)
await _tools(mcp)["create_knowledge_page"].fn(
name="Deploys", source_query="q", tags=["ops"], max_tokens=512, refresh_after_consolidation=False
)
create_kwargs = mock_memory.create_knowledge_page.call_args.kwargs
assert create_kwargs["trigger"] == {"refresh_after_consolidation": False}
assert create_kwargs["tags"] == ["ops"]
assert create_kwargs["max_tokens"] == 512
async def test_create_page_duplicate_name(self, mock_memory):
mock_memory.create_knowledge_page.return_value = None
mcp = _make_mcp_server(mock_memory, {"create_knowledge_page"}, include_bank_id=True)
result = await _tools(mcp)["create_knowledge_page"].fn(name="Deploys", source_query="q")
assert "already exists" in result
mock_memory.submit_async_refresh_mental_model.assert_not_called()
async def test_update_rename(self, mock_memory):
mcp = _make_mcp_server(mock_memory, {"update_knowledge_node"}, include_bank_id=True)
result = json.loads(await _tools(mcp)["update_knowledge_node"].fn(node_id="kp-1", name="Deployments"))
assert result["id"] == "kp-1"
assert mock_memory.rename_knowledge_node.call_args.kwargs["name"] == "Deployments"
mock_memory.update_knowledge_page.assert_not_called()
mock_memory.move_knowledge_node.assert_not_called()
async def test_update_move_to_folder(self, mock_memory):
mcp = _make_mcp_server(mock_memory, {"update_knowledge_node"}, include_bank_id=True)
await _tools(mcp)["update_knowledge_node"].fn(node_id="kp-1", parent_id="kf-2")
assert mock_memory.move_knowledge_node.call_args.kwargs["new_parent_id"] == "kf-2"
async def test_update_move_to_root(self, mock_memory):
mcp = _make_mcp_server(mock_memory, {"update_knowledge_node"}, include_bank_id=True)
await _tools(mcp)["update_knowledge_node"].fn(node_id="kp-1", parent_id=KNOWLEDGE_ROOT_PARENT)
assert mock_memory.move_knowledge_node.call_args.kwargs["new_parent_id"] is None
async def test_update_source_query_triggers_refresh(self, mock_memory):
mcp = _make_mcp_server(mock_memory, {"update_knowledge_node"}, include_bank_id=True)
await _tools(mcp)["update_knowledge_node"].fn(node_id="kp-1", source_query="new question?")
assert mock_memory.update_knowledge_page.call_args.kwargs["source_query"] == "new question?"
assert mock_memory.submit_async_refresh_mental_model.call_args.kwargs["mental_model_id"] == "mm-page"
async def test_update_tags_only_does_not_refresh(self, mock_memory):
mcp = _make_mcp_server(mock_memory, {"update_knowledge_node"}, include_bank_id=True)
await _tools(mcp)["update_knowledge_node"].fn(node_id="kp-1", tags=[])
assert mock_memory.update_knowledge_page.call_args.kwargs["tags"] == []
mock_memory.submit_async_refresh_mental_model.assert_not_called()
async def test_update_requires_a_field(self, mock_memory):
mcp = _make_mcp_server(mock_memory, {"update_knowledge_node"}, include_bank_id=True)
result = await _tools(mcp)["update_knowledge_node"].fn(node_id="kp-1")
assert "Provide name" in result
mock_memory.rename_knowledge_node.assert_not_called()
mock_memory.update_knowledge_page.assert_not_called()
async def test_update_not_found(self, mock_memory):
mock_memory.rename_knowledge_node.return_value = None
mcp = _make_mcp_server(mock_memory, {"update_knowledge_node"}, include_bank_id=True)
result = await _tools(mcp)["update_knowledge_node"].fn(node_id="kp-missing", name="x")
assert "not found" in result
async def test_delete_node(self, mock_memory):
mcp = _make_mcp_server(mock_memory, {"delete_knowledge_node"}, include_bank_id=True)
result = json.loads(await _tools(mcp)["delete_knowledge_node"].fn(node_id="kp-1"))
assert result == {"status": "deleted", "node_id": "kp-1"}
async def test_delete_node_not_found(self, mock_memory):
mock_memory.delete_knowledge_node.return_value = False
mcp = _make_mcp_server(mock_memory, {"delete_knowledge_node"}, include_bank_id=True)
result = await _tools(mcp)["delete_knowledge_node"].fn(node_id="kp-missing")
assert "not found" in result
async def test_no_bank_configured(self, mock_memory):
from fastmcp import FastMCP
mcp = FastMCP("test")
register_mcp_tools(
mcp,
mock_memory,
MCPToolsConfig(bank_id_resolver=lambda: None, include_bank_id_param=True, tools=_KB_TOOLS),
)
result = await _tools(mcp)["get_knowledge_base_tree"].fn()
assert "No bank_id configured" in result
async def test_request_context_is_propagated(self, mock_memory):
from fastmcp import FastMCP
mcp = FastMCP("test")
register_mcp_tools(
mcp,
mock_memory,
MCPToolsConfig(
bank_id_resolver=lambda: "test-bank",
api_key_resolver=lambda: "secret",
include_bank_id_param=True,
tools={"get_knowledge_base_tree"},
),
)
await _tools(mcp)["get_knowledge_base_tree"].fn()
assert mock_memory.list_knowledge_nodes.call_args.kwargs["request_context"].api_key == "secret"
async def test_read_only_annotations(self, mock_memory):
mcp = _make_mcp_server(mock_memory, _KB_TOOLS)
tools = _tools(mcp)
for name in ("get_knowledge_base_tree", "search_knowledge_base", "get_knowledge_page"):
assert tools[name].annotations.readOnlyHint is True, name
assert tools["delete_knowledge_node"].annotations.destructiveHint is True
assert tools["create_knowledge_page"].annotations.destructiveHint is False
# =========================================================================
# Bank-Level Tool Filtering Tests
# =========================================================================
@@ -21,6 +21,13 @@ from hindsight_api.engine.retain import embedding_processing
# ---------------------------------------------------------------------------
# Most of this module seeds its fixtures by INSERTing memory_units / memory_links /
# entities directly with the helpers below, then asserts on those rows (including the
# embedding and search_vector columns). Those classes and tests carry
# ``memory_backend_incompatible``; the handful that go through the engine end to end
# — test_not_found_returns_none, test_recall_excludes_invalidated — deliberately do not.
async def _insert_memory(
conn,
memory: MemoryEngine,
@@ -185,12 +192,11 @@ async def _entity_ids_for(conn, unit_id: uuid.UUID) -> list[uuid.UUID]:
return [r["entity_id"] for r in rows]
async def _obs_ids(conn, bank_id: str) -> list[str]:
rows = await conn.fetch(
"SELECT id FROM memory_units WHERE bank_id = $1 AND fact_type = 'observation'",
bank_id,
async def _obs_ids(memory: MemoryEngine, bank_id: str, request_context: RequestContext) -> list[str]:
listing = await memory.list_memory_units(
bank_id, fact_type="observation", limit=1000, request_context=request_context
)
return [str(r["id"]) for r in rows]
return [item["id"] for item in listing["items"]]
async def _consolidated_at(conn, mem_id: uuid.UUID):
@@ -207,6 +213,8 @@ async def _ensure_bank(memory: MemoryEngine, bank_id: str, request_context: Requ
class TestInvalidate:
pytestmark = pytest.mark.memory_backend_incompatible
@pytest.mark.asyncio
async def test_invalidate_moves_to_archive_and_prunes(self, memory: MemoryEngine, request_context: RequestContext):
bank_id = f"test-curation-inv-{uuid.uuid4().hex[:8]}"
@@ -250,7 +258,7 @@ class TestInvalidate:
"archive is cold storage with no index; the schema drops search_vector (#2503)"
)
assert await _link_count(conn, m1) == 0, "links cascade-pruned on move"
assert str(obs_id) not in await _obs_ids(conn, bank_id), "derived observation removed"
assert str(obs_id) not in await _obs_ids(memory, bank_id, request_context), "derived observation removed"
assert await _consolidated_at(conn, m2) is None, "surviving source reset for re-consolidation"
await memory.delete_bank(bank_id, request_context=request_context)
@@ -334,6 +342,8 @@ class TestInvalidate:
class TestEdit:
pytestmark = pytest.mark.memory_backend_incompatible
@pytest.mark.asyncio
async def test_edit_changes_text_and_rederives(self, memory: MemoryEngine, request_context: RequestContext):
bank_id = f"test-curation-edit-{uuid.uuid4().hex[:8]}"
@@ -385,7 +395,7 @@ class TestEdit:
assert row["consolidated_at"] is None, "edited memory re-consolidates"
assert "'assist'" not in row["search_vector"], "old text must not stay in native FTS search_vector"
assert "'user'" in row["search_vector"], "new text must refresh native FTS search_vector"
assert str(obs_id) not in await _obs_ids(conn, bank_id), "stale observation re-derived"
assert str(obs_id) not in await _obs_ids(memory, bank_id, request_context), "stale observation re-derived"
queued_ids = await conn.fetch("SELECT unit_id FROM graph_maintenance_queue WHERE bank_id = $1", bank_id)
assert {row["unit_id"] for row in queued_ids} == {m1, m2}, "edited memory and incoming victim both queued"
@@ -468,6 +478,93 @@ class TestEdit:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def _near_duplicate_entity_bank(self, memory: MemoryEngine, request_context: RequestContext):
"""A bank in the shape reported in #3479, returned as (bank_id, unit_id, typo_entity_id).
Extraction created a typo entity ("Dr Wall") that co-occurs with the other name in the
edit, so fuzzy scoring puts it above the 0.6 match threshold for the corrected spelling
"Dr. Waller" name similarity 0.41 plus the full 0.3 co-occurrence bonus.
"""
bank_id = f"test-curation-entmode-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
unit_id = await _insert_memory(conn, memory, bank_id, "Dr. Waller referred the patient to CareOrg.")
typo = await _insert_entity(conn, bank_id, "Dr Wall")
careorg = await _insert_entity(conn, bank_id, "CareOrg")
await _link_entity(conn, unit_id, typo)
await _link_entity(conn, unit_id, careorg)
# The co-occurrence edge is what lets the typo entity outscore the corrected name.
await conn.execute(
"INSERT INTO entity_cooccurrences (entity_id_1, entity_id_2, cooccurrence_count) VALUES ($1, $2, 5)",
*sorted([typo, careorg], key=str),
)
return bank_id, unit_id, typo
@pytest.mark.asyncio
async def test_edit_entities_unresolved_keeps_the_submitted_names(
self, memory: MemoryEngine, request_context: RequestContext
):
"""resolve_entities=False links the names the caller wrote (#3479)."""
bank_id, m1, typo = await self._near_duplicate_entity_bank(memory, request_context)
pool = await memory._get_pool()
with (
patch.object(memory, "submit_async_consolidation", new=AsyncMock()),
patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()),
):
result = await memory.update_memory_unit(
bank_id,
str(m1),
entities=["Dr. Waller", "CareOrg"],
resolve_entities=False,
request_context=request_context,
)
assert set(result["entities"]) == {"Dr. Waller", "CareOrg"}, "the submitted names are stored verbatim"
async with pool.acquire() as conn:
linked = await conn.fetch(
"SELECT e.canonical_name FROM unit_entities ue "
"JOIN entities e ON e.id = ue.entity_id WHERE ue.unit_id = $1",
m1,
)
assert {r["canonical_name"] for r in linked} == {"Dr. Waller", "CareOrg"}
assert typo not in await _entity_ids_for(conn, m1), "the typo entity is detached, not reused"
assert await conn.fetchval("SELECT 1 FROM entities WHERE id = $1", typo), (
"the typo entity itself survives for the graph-maintenance sweep to reclaim"
)
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_edit_entities_default_still_resolves(self, memory: MemoryEngine, request_context: RequestContext):
"""Omitting the flag keeps retain's resolution, so existing callers are unaffected.
This pins the backwards-compatible default rather than endorsing the outcome: the same
edit that resolve_entities=False gets right lands on the near-duplicate here.
"""
bank_id, m1, typo = await self._near_duplicate_entity_bank(memory, request_context)
pool = await memory._get_pool()
with (
patch.object(memory, "submit_async_consolidation", new=AsyncMock()),
patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()),
):
result = await memory.update_memory_unit(
bank_id,
str(m1),
entities=["Dr. Waller", "CareOrg"],
request_context=request_context,
)
assert set(result["entities"]) == {"Dr Wall", "CareOrg"}, "the default still resolves fuzzily"
async with pool.acquire() as conn:
assert typo in await _entity_ids_for(conn, m1)
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_edit_empty_entities_detaches_all(self, memory: MemoryEngine, request_context: RequestContext):
bank_id = f"test-curation-editent0-{uuid.uuid4().hex[:8]}"
@@ -529,6 +626,8 @@ class TestCurationRelinking:
returns, the links are already rebuilt.
"""
pytestmark = pytest.mark.memory_backend_incompatible
@pytest.mark.asyncio
async def test_edit_with_only_outgoing_links_queues_itself(
self, memory: MemoryEngine, request_context: RequestContext
@@ -622,6 +721,7 @@ class TestCurationRelinking:
class TestGuardsAndListing:
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_cannot_curate_observation(self, memory: MemoryEngine, request_context: RequestContext):
bank_id = f"test-curation-obs-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
@@ -647,6 +747,7 @@ class TestGuardsAndListing:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_list_filters_by_state(self, memory: MemoryEngine, request_context: RequestContext):
bank_id = f"test-curation-list-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
@@ -682,6 +783,7 @@ class TestGuardsAndListing:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_list_and_get_memory_units_include_metadata(
self, memory: MemoryEngine, request_context: RequestContext
):
@@ -723,6 +825,7 @@ class TestGuardsAndListing:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_list_filters_by_document(self, memory: MemoryEngine, request_context: RequestContext):
bank_id = f"test-curation-doc-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
@@ -762,6 +865,7 @@ class TestGuardsAndListing:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_list_filters_by_entity(self, memory: MemoryEngine, request_context: RequestContext):
bank_id = f"test-curation-entity-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
@@ -865,6 +969,8 @@ class TestCausalLinkPreservation:
invalidate/revert round-trip must carry them through the archive.
"""
pytestmark = pytest.mark.memory_backend_incompatible
@pytest.mark.asyncio
async def test_edit_preserves_causal_links_and_drops_derived(
self, memory: MemoryEngine, request_context: RequestContext
@@ -569,7 +569,7 @@ async def test_retain_allows_clean_content(api_client) -> None:
@pytest.mark.asyncio
async def test_retain_stores_redacted_text(api_client, memory) -> None:
async def test_retain_stores_redacted_text(api_client, memory, request_context) -> None:
await api_client.put("/v1/default/banks/md-retain-2", json={})
await _set_policy(api_client, "md-retain-2", _REDACT_POLICY)
secret = "ghp_" + "A" * 36
@@ -578,8 +578,8 @@ async def test_retain_stores_redacted_text(api_client, memory) -> None:
json={"items": [{"content": f"my token is {secret}"}]},
)
assert r.status_code == 200, r.text
async with memory._pool.acquire() as conn:
texts = [row["text"] for row in await conn.fetch("SELECT text FROM memory_units WHERE bank_id = 'md-retain-2'")]
listing = await memory.list_memory_units("md-retain-2", limit=1000, request_context=request_context)
texts = [item["text"] for item in listing["items"]]
assert all(secret not in t for t in texts), texts
@@ -37,6 +37,10 @@ from hindsight_api.engine.db.ops_postgresql import PostgreSQLOps
from hindsight_api.engine.db.postgresql import PostgresConnection
from hindsight_api.engine.retain.link_utils import _bulk_insert_links
# Asserts a raw memory_links row count around a concurrent delete; the graph read
# path dedupes bidirectional edges, so the count is not reproducible through it.
pytestmark = pytest.mark.memory_backend_incompatible
async def _insert_unit(conn: asyncpg.Connection, bank_id: str) -> str:
"""Insert one committed memory_unit (autocommit) and return its id as text."""
@@ -84,6 +84,7 @@ async def _bank(memory: MemoryEngine, slug: str, request_context: RequestContext
class TestWritesThatMustStamp:
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_document_tag_propagation_stamps_updated_at(
self, memory: MemoryEngine, request_context: RequestContext
):
@@ -109,6 +110,7 @@ class TestWritesThatMustStamp:
assert await _updated_at(conn, mem_id) > _BASELINE
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_embedding_write_stamps_updated_at(self, memory: MemoryEngine, request_context: RequestContext):
"""The stored vector is part of the memory, so the store method stamps on its own.
@@ -214,6 +216,7 @@ class TestConsolidationBookkeepingIsExempt:
assert await _updated_at(conn, mem_id) == _BASELINE
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_requeue_after_observation_cleanup_leaves_updated_at_alone(
self, memory: MemoryEngine, request_context: RequestContext
):
@@ -97,6 +97,7 @@ async def test_tagged_strict_model_skipped_when_only_untagged_consolidated(
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_tagged_non_strict_model_refreshed_when_only_untagged_consolidated(
memory: MemoryEngine, request_context, monkeypatch
):
@@ -116,6 +117,7 @@ async def test_tagged_non_strict_model_refreshed_when_only_untagged_consolidated
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_tag_groups_model_refreshed_when_only_untagged_consolidated(
memory: MemoryEngine, request_context, monkeypatch
):
@@ -157,6 +159,7 @@ async def test_non_strict_model_skipped_when_nothing_changed(memory: MemoryEngin
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_tagged_model_refreshed_when_its_tag_was_consolidated(memory: MemoryEngine, request_context, monkeypatch):
"""The overlap path is unchanged: a strict tagged model is refreshed when a memory
carrying its tag was consolidated."""
@@ -174,6 +177,7 @@ async def test_tagged_model_refreshed_when_its_tag_was_consolidated(memory: Memo
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_non_strict_model_refreshed_on_mixed_run_with_foreign_tags(
memory: MemoryEngine, request_context, monkeypatch
):
@@ -275,6 +275,7 @@ class TestDeltaRefreshPlumbing:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.memory_backend_incompatible
async def test_delta_no_new_facts_advances_watermark_to_newest_processed(
self,
memory: MemoryEngine,
@@ -403,6 +404,7 @@ class TestDeltaRefreshPlumbing:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.memory_backend_incompatible
async def test_delta_refresh_watermark_survives_straddling_commit(
self,
memory: MemoryEngine,
@@ -193,6 +193,7 @@ async def test_routine_returns_cron_models_excludes_plain_and_in_flight(memory:
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_due_and_stale_model_is_refreshed(memory: MemoryEngine, request_context, monkeypatch):
"""A model whose cron is due and that has new memories in scope is refreshed."""
bank = await _make_bank(memory, request_context)
@@ -1173,6 +1173,7 @@ class TestMentalModelStaleness:
assert got["is_stale"] is False
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.memory_backend_incompatible
async def test_untagged_mm_stale_on_any_new_memory(self, memory: MemoryEngine, request_context):
bank_id = f"test-mm-stale-untagged-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
@@ -1201,6 +1202,7 @@ class TestMentalModelStaleness:
assert got["is_stale"] is False
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.memory_backend_incompatible
async def test_tagged_mm_defaults_to_all_strict(self, memory: MemoryEngine, request_context):
bank_id = f"test-mm-stale-overlap-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
@@ -1221,6 +1223,7 @@ class TestMentalModelStaleness:
assert got["is_stale"] is True
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.memory_backend_incompatible
async def test_tags_match_any_keeps_overlap_behavior(self, memory: MemoryEngine, request_context):
bank_id = f"test-mm-stale-any-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
@@ -1238,6 +1241,7 @@ class TestMentalModelStaleness:
assert got["is_stale"] is True
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.memory_backend_incompatible
async def test_tag_groups_define_stale_scope(self, memory: MemoryEngine, request_context):
bank_id = f"test-mm-stale-groups-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
@@ -1262,6 +1266,7 @@ class TestMentalModelStaleness:
assert got["is_stale"] is True
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.memory_backend_incompatible
async def test_flat_tags_and_fact_types_share_stale_scope(self, memory: MemoryEngine, request_context):
bank_id = f"test-mm-stale-flat-fact-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
@@ -1283,6 +1288,7 @@ class TestMentalModelStaleness:
assert got["is_stale"] is True
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.memory_backend_incompatible
async def test_tag_groups_and_fact_types_share_stale_scope(self, memory: MemoryEngine, request_context):
bank_id = f"test-mm-stale-group-fact-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
@@ -1328,6 +1334,7 @@ class TestMentalModelStaleness:
assert got["is_stale"] is True
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.memory_backend_incompatible
async def test_tags_match_all_strict_requires_all_tags(self, memory: MemoryEngine, request_context):
"""tags_match='all_strict' → memory must contain ALL MM tags (and be tagged)."""
bank_id = f"test-mm-stale-all-{uuid.uuid4().hex[:8]}"
@@ -1353,6 +1360,7 @@ class TestMentalModelStaleness:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.memory_backend_incompatible
async def test_tags_match_any_strict_excludes_untagged(self, memory: MemoryEngine, request_context):
"""tags_match='any_strict' → untagged memory does NOT keep MM in scope."""
bank_id = f"test-mm-stale-anystrict-{uuid.uuid4().hex[:8]}"
@@ -1376,6 +1384,7 @@ class TestMentalModelStaleness:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.memory_backend_incompatible
async def test_fact_type_filter_narrows_scope(self, memory: MemoryEngine, request_context):
bank_id = f"test-mm-stale-fact-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
@@ -1399,6 +1408,7 @@ class TestMentalModelStaleness:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.memory_backend_incompatible
async def test_tool_search_mental_models_returns_is_stale_per_mm(self, memory: MemoryEngine, request_context):
"""Regression: tool_search_mental_models must compute is_stale per-MM via scope,
not via a bank-wide pending_consolidation short-circuit."""
@@ -1435,6 +1445,7 @@ class TestMentalModelStaleness:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.memory_backend_incompatible
async def test_tool_search_mental_models_skips_the_scan_below_the_watermark(
self, memory: MemoryEngine, request_context
):
@@ -1621,6 +1632,7 @@ class TestMentalModelRefreshTimestamps:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.memory_backend_incompatible
async def test_staleness_keys_off_memories_seen_not_refresh_time(self, memory: MemoryEngine, request_context):
"""The inverse regression: making ``last_refreshed_at`` a wall clock must not let
a recent refresh mask a memory the document has never seen."""
@@ -2585,6 +2597,7 @@ class TestMentalModelRefreshFactTypeFilter:
memory._reflect_llm_config = wrapper
return mock_llm
@pytest.mark.memory_backend_incompatible
async def test_refresh_with_fact_types_experience_grounds_on_experience_facts(
self, memory: MemoryEngine, request_context
):
@@ -22,6 +22,10 @@ from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, text
# Drives alembic and asserts on the search_vector column itself — an internal FTS
# index column, not part of the public read model.
pytestmark = pytest.mark.memory_backend_incompatible
_SCRIPT_LOCATION = str(Path(__file__).parent.parent / "hindsight_api" / "alembic")
# Revision immediately before the backfill migration.
@@ -163,3 +163,73 @@ async def test_wide_source_arrays_do_not_change_results(memory, request_context)
assert scores[narrow] == scores[wide] == 1.0
finally:
await memory.delete_bank(bank_id=bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_per_entity_cap_bounds_hub_traversal(memory, request_context):
"""A hub entity contributes at most ``per_entity_limit`` source facts (#3510).
The cap used to be a LATERAL + LIMIT and is now a row_number() window, because
the planner cannot estimate DISTINCT over a LIMIT subquery and mis-planned the
scoring join into a nested loop. The two forms have to select the *same* rows:
the highest ``per_entity_limit`` unit_ids of each entity. Ranking is by unit_id
descending, which is what the LATERAL ordered by, so a candidate built from the
lowest ids of an over-cap entity must fall outside the cap and score nothing.
"""
from hindsight_api.engine.db.ops import UpdatedWindow
from hindsight_api.engine.task_backend import fq_table
bank_id = f"test_obs_cap_{uuid.uuid4().hex[:8]}"
per_entity_limit = 3
try:
pool = await memory._get_pool()
backend = await memory._get_backend()
mu, ue, ml = fq_table("memory_units"), fq_table("unit_entities"), fq_table("memory_links")
async with pool.acquire() as conn:
await _ensure_bank(conn, bank_id)
entity_id = uuid.uuid4()
await conn.execute(
f"INSERT INTO {fq_table('entities')} (id, bank_id, canonical_name) VALUES ($1, $2, $3)",
entity_id,
bank_id,
"Hub",
)
# Six facts on one entity with ids we control, so "top 3 by unit_id
# descending" is a known set rather than an accident of uuid4().
facts = sorted(uuid.UUID(int=i) for i in range(1, 7))
for fid in facts:
await conn.execute(
f"""
INSERT INTO {mu} (id, bank_id, text, fact_type, source_memory_ids, event_date)
VALUES ($1, $2, $3, 'world', NULL, $4)
""",
fid,
bank_id,
f"hub fact {fid}",
datetime.now(timezone.utc),
)
await conn.execute(f"INSERT INTO {ue} (unit_id, entity_id) VALUES ($1, $2)", fid, entity_id)
# The seed reaches the hub through its own source fact.
seed = await _insert_unit(conn, mu, bank_id, "seed obs", "observation", [facts[0]])
# Inside the cap: built from the highest ids. Outside: the lowest.
inside = await _insert_unit(conn, mu, bank_id, "inside cap", "observation", facts[-3:])
outside = await _insert_unit(conn, mu, bank_id, "outside cap", "observation", [facts[1]])
rows = await backend.ops.expand_observations(
conn,
mu,
ue,
ml,
[seed],
100,
per_entity_limit,
UpdatedWindow(after=None, before=None, first_param_index=3),
)
scores = {r["id"] for r in rows.entity}
assert inside in scores, "observations built from the top-ranked ids must be reachable"
assert outside not in scores, "the per-entity cap must exclude ids ranked below it"
finally:
await memory.delete_bank(bank_id=bank_id, request_context=request_context)
+11 -31
View File
@@ -167,17 +167,15 @@ async def test_observation_fact_type_in_database(memory, request_context, disabl
await memory.wait_for_background_tasks()
# Check that NO observations exist in memory_units
pool = await memory._get_pool()
async with pool.acquire() as conn:
observations = await conn.fetch(
"""
SELECT id, text, fact_type, context
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
""",
# Check that NO observations exist as memory units
observations = (
await memory.list_memory_units(
bank_id,
fact_type="observation",
limit=1000,
request_context=request_context,
)
)["items"]
print(f"\n=== Observation Records in memory_units ===")
print(f"Found {len(observations)} observation records (should be 0)")
@@ -194,6 +192,7 @@ async def test_observation_fact_type_in_database(memory, request_context, disabl
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_entity_mention_counts(memory, request_context):
"""
Test that entity mention counts are tracked correctly.
@@ -235,17 +234,7 @@ async def test_entity_mention_counts(memory, request_context):
await memory.wait_for_background_tasks()
# Check entity mention counts
pool = await memory._get_pool()
async with pool.acquire() as conn:
entities = await conn.fetch(
"""
SELECT e.id, e.canonical_name, e.mention_count
FROM entities e
WHERE e.bank_id = $1
ORDER BY e.mention_count DESC
""",
bank_id,
)
entities = (await memory.list_entities(bank_id, limit=1000, request_context=request_context))["items"]
print(f"\n=== Entity Mention Counts Test ===")
print(f"Total entities: {len(entities)}")
@@ -283,6 +272,7 @@ async def test_entity_mention_counts(memory, request_context):
@pytest.mark.asyncio
@pytest.mark.timeout(1200)
@pytest.mark.memory_backend_incompatible
async def test_entity_mention_ranking(memory, request_context):
"""
Test that entity mention counts correctly rank entities.
@@ -322,17 +312,7 @@ async def test_entity_mention_ranking(memory, request_context):
# Phase 3: Verify entities are ranked by mention count
print("\n=== Phase 3: Check entity ranking ===")
pool = await memory._get_pool()
async with pool.acquire() as conn:
all_entities = await conn.fetch(
"""
SELECT canonical_name, mention_count
FROM entities
WHERE bank_id = $1
ORDER BY mention_count DESC
""",
bank_id,
)
all_entities = (await memory.list_entities(bank_id, limit=1000, request_context=request_context))["items"]
print(f"\nAll entities by mention count:")
for e in all_entities:
@@ -207,6 +207,7 @@ class TestMarkOperationGracefulOnMissingRow:
class TestConsolidationCheckpoint:
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_consolidation_stops_early_when_op_cancelled(self, memory: MemoryEngine, request_context):
"""Consolidation returns 'cancelled' status after the first batch if _check_op_alive is False."""
from hindsight_api.config import _get_raw_config
@@ -161,6 +161,7 @@ async def test_progress_absent_returns_null(api_client, memory: MemoryEngine):
@pytest.mark.asyncio
@pytest.mark.memory_backend_incompatible
async def test_consolidation_records_advancing_progress(memory: MemoryEngine, request_context, monkeypatch):
"""A real consolidation run emits scanning → processing_batch → refreshing_mental_models,
with processed advancing and a durable snapshot left on the operation row."""
@@ -37,6 +37,9 @@ except ImportError:
pytestmark = [
pytest.mark.skipif(not ORACLEDB_AVAILABLE, reason="oracledb not installed"),
pytest.mark.skipif(not os.getenv("ORACLE_TEST_DSN"), reason="ORACLE_TEST_DSN not set"),
# Talks to the backend's own tables in SQL, down to VECTOR_DISTANCE over the
# embedding column.
pytest.mark.memory_backend_incompatible,
]
@@ -35,6 +35,18 @@ def _bank_id(prefix: str = "oracle") -> str:
return f"test-{prefix}-{uuid.uuid4().hex[:8]}"
def _as_utc(value: datetime | None) -> datetime | None:
"""Attach UTC to a timestamp oracledb handed back naive.
Values are written as UTC-aware, but the driver reads some timestamp columns back
without a tzinfo, and a naive/aware comparison is silently False rather than an error.
Normalise before comparing instants.
"""
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
async def _safe_cleanup(memory: MemoryEngine, bank_id: str, request_context: RequestContext) -> None:
"""Delete a bank, suppressing deadlock/lock errors in test teardown.
@@ -1154,6 +1166,97 @@ class TestOracleSpecific:
run_migrations(oracle_db_url)
run_migrations(oracle_db_url)
@pytest.mark.asyncio
async def test_observation_update_widens_null_bounds(
self, oracle_memory: MemoryEngine, request_context: RequestContext
):
"""#3477 on Oracle: an observation with NO occurred interval must inherit its source's.
Oracle's LEAST/GREATEST return NULL as soon as any argument is NULL, where PostgreSQL
ignores NULL arguments. A plain ``LEAST(occurred_start, COALESCE(:n, occurred_start))``
therefore evaluates to NULL for an observation that has no interval yet silently
dropping the very dates it was told to inherit while the identical statement is
correct on PostgreSQL. The PG-side coverage lives in test_consolidation_temporal_merge.py;
only this test can catch the dialect difference.
"""
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.consolidation.consolidator import (
_execute_create_action,
_execute_update_action,
_TemporalBounds,
)
from hindsight_api.engine.response_models import MemoryFact
early = datetime(2020, 3, 1, tzinfo=timezone.utc)
late = datetime(2021, 7, 4, 12, 30, tzinfo=timezone.utc)
bank_id = _bank_id("obsbounds")
config = _get_raw_config()
previous_observations = config.enable_observations
config.enable_observations = False # the test drives consolidation itself
try:
await oracle_memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
await oracle_memory.retain_async(
bank_id=bank_id,
content="Dana learned to sail.",
request_context=request_context,
)
backend = await oracle_memory._get_backend()
async with backend.acquire() as conn:
source_id = await conn.fetchval(
"SELECT id FROM memory_units WHERE bank_id = $1 AND fact_type <> 'observation'",
bank_id,
)
assert source_id is not None, "retain produced no fact to build an observation from"
# An observation built from an undated fact: event_date/mentioned_at are stamped at
# creation time, occurred_start/occurred_end stay NULL.
action = await _execute_create_action(
pool=backend,
memory_engine=oracle_memory,
bank_id=bank_id,
source_memory_ids=[source_id],
text="Dana learned to sail.",
)
assert action == "created"
async with backend.acquire() as conn:
seeded = await conn.fetchrow(
"SELECT id, occurred_start, occurred_end FROM memory_units "
"WHERE bank_id = $1 AND fact_type = 'observation'",
bank_id,
)
assert seeded["occurred_start"] is None and seeded["occurred_end"] is None
await _execute_update_action(
pool=backend,
memory_engine=oracle_memory,
bank_id=bank_id,
source_memory_ids=[source_id],
observation_id=str(seeded["id"]),
new_text="Dana learned to sail on the Adriatic.",
observations=[
MemoryFact(
id=str(seeded["id"]),
text="Dana learned to sail.",
fact_type="observation",
source_fact_ids=[str(source_id)],
tags=[],
)
],
source_bounds=_TemporalBounds(occurred_start=early, occurred_end=late),
)
async with backend.acquire() as conn:
updated = await conn.fetchrow(
"SELECT occurred_start, occurred_end FROM memory_units WHERE id = $1",
seeded["id"],
)
assert _as_utc(updated["occurred_start"]) == early, "a NULL occurred_start must take the source's date"
assert _as_utc(updated["occurred_end"]) == late, "a NULL occurred_end must take the source's date"
finally:
config.enable_observations = previous_observations
await _safe_cleanup(oracle_memory, bank_id, request_context)
# ===================================================================
# Tier 6 — Edge Cases & Robustness

Some files were not shown because too many files have changed in this diff Show More