Compare commits

...
Author SHA1 Message Date
Nicolò Boschi dbb0ada924 release(opencode): v0.2.3 2026-06-08 12:28:18 +02:00
Nicolò Boschi 796a9eff91 fix(opencode): observable logging — config-only debug, resolved-endpoint log, surfaced errors (#2047)
* fix(opencode): observable logging — config-only debug, resolved-endpoint log, surfaced errors

OpenCode users (notably on Windows) could see tool calls register but no
memories land, with zero signal as to why: every retain/recall failure was
swallowed via debugLog, the resolved API URL/bank was only logged when debug
was on, and HINDSIGHT_DEBUG is unreliable to set for OpenCode's plugin runtime.

- Add a Logger that routes through OpenCode's server log stream
  (client.app.log, service=hindsight) — TUI-safe, visible via --print-logs and
  the OpenCode log files. Falls back to console.error when no client.
- error/warn/info are always emitted; debug is gated on config.debug.
- Always log the resolved endpoint + bank at init (a common 'memories aren't
  saving' cause is silently defaulting to Hindsight Cloud).
- Surface retain/recall/hook failures as errors instead of swallowing them;
  hooks still never throw, so OpenCode is not affected.
- Drop the HINDSIGHT_DEBUG env override; 'debug' is now a config-only option
  (opencode.json plugin options or ~/.hindsight/opencode.json).
- Tests for the logger; update config tests; document the change.

Refs #1758

* style(opencode): prettier-format plugin.test.ts (pre-existing drift)

* docs(opencode): document config-only debug + default error/endpoint logging
2026-06-08 12:27:32 +02:00
Nicolò Boschi e774617625 feat(consolidation): periodic reconcile + cross-tenant retention via maintenance loop (#1969) (#2019)
* feat(consolidation): periodic reconcile + cross-tenant retention via maintenance loop (#1969)

Add a single background MaintenanceLoop (engine/maintenance.py) started in
MemoryEngine.initialize(), replacing the two per-recorder retention sweep tasks.
One ~60s tick runs each job on its own interval:

- Consolidation reconcile (HINDSIGHT_API_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS,
  default 300, 0=off): re-schedules consolidation for banks with eligible-but-
  unscheduled facts and no in-flight consolidation, recovering facts stranded
  when a consolidation operation failed terminally (#1969).
- Retention sweeps (hourly) for audit_log and llm_requests, now across ALL tenant
  schemas (the old sweeps only swept the base schema).

Cross-tenant discovery uses server-side PL/pgSQL routines (migration
e5f6a7b8c9d0): public.banks_needing_consolidation() and
public.schemas_with_expired_rows(table, ts_col, days) — one round-trip each
instead of a per-schema query storm at scale. Config gating resolves the full
hierarchy per returned bank (global/tenant/bank); Tenant gains an optional
tenant_id so tenant-layer overrides are honored.

* fix(consolidation): gate maintenance loop to PostgreSQL

The retention sweeps target PG-only tables and the reconcile relies on PG-only
PL/pgSQL routines, so on Oracle every tick would call non-existent functions and
spam warnings. Skip starting the loop when the backend is Oracle (mirrors the
PG-only migration).

* test(consolidation): 100-tenant maintenance loop targeting test

Provisions 100 tenant schemas (cloning the five tables the loop touches) and
verifies each job affects only the tenants it should: audit-log and llm-request
retention purge expired rows only in schemas that have them (recent rows kept
everywhere), and the consolidation reconcile enqueues only the eligible banks
into their own schema — skipping auto-consolidation-disabled, in-flight, and
already-consolidated banks.

* fix(migration): chain maintenance routines after the split-history head

After rebasing onto main, the maintenance-routines migration and #2007's
split-history migration (a7b8c9d0e1f2) both pointed at d3e4f5a6b7c8, creating two
alembic heads (test_single_head failed). Re-point down_revision to a7b8c9d0e1f2
so the tree is a single linear head again.

* fix(maintenance): create public routines once + stop loop racing tests

Two CI failures from the maintenance work:

1. Migration ran CREATE OR REPLACE FUNCTION public.* on every per-schema
   migration; concurrent tenant provisioning collided on the pg_proc catalog
   ('tuple concurrently updated'). Create the shared public routines only on the
   base-schema run (target_schema unset); tenant runs skip them.

2. The maintenance loop auto-starts in every test engine (llm-trace retention is
   on by default), and its background sweep deleted llm_requests rows that
   test_maintenance_multitenant had just inserted. Disable llm-trace retention in
   the test env too, so with reconcile already off and audit retention off by
   default no job is enabled and the loop never starts; tests drive it directly.
2026-06-08 11:59:07 +02:00
zwcf5200andNicolò Boschi aa024a5cde feat(recall): add configurable HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY (#2039)
* feat(recall): make semantic threshold configurable

* refactor(recall): rename semantic_threshold to semantic_min_similarity

Align the new semantic gate with its sibling BM25_MIN_SCORE: per-strategy
prefix, and 'min_similarity' since the value is a cosine similarity. Renames
the env var (HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY), config field, and the
build_semantic_arm parameter (min_similarity).

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-08 11:28:15 +02:00
Evo 227441a302 docs(configuration): document HINDSIGHT_API_DATABASE_BACKEND (postgresql|oracle) (#2024)
* docs(configuration): document HINDSIGHT_API_DATABASE_BACKEND (postgresql|oracle)

* docs(configuration): document HINDSIGHT_API_DATABASE_BACKEND (postgresql|oracle)
2026-06-08 11:16:13 +02:00
Minghao XiaoandNicolò Boschi 831f0efa10 fix(retain): expose retain outcome metadata (#2041)
* fix(retain): expose retain outcome metadata

* fix(retain): avoid double-counting batch extraction errors; drop dup json parse

- _write_batch_extraction_errors overwrites extraction_errors_* instead of
  folding in stored counters, which double-counted on batch crash recovery
  (resumed batch reprocesses all results and recomputes errors from scratch).
- Remove now-unused _parse_result_metadata helper and merge_errors method.
- Log retain-outcome-metadata write failures at warning (not debug): a missing
  write silently regresses clients to the ambiguous pre-fix behaviour.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-08 11:14:32 +02:00
Evo bd60c7575c docs(models): document the onnx embeddings provider (#2020) 2026-06-08 11:14:16 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> cc6fc94468 chore(deps): bump the uv group across 4 directories with 6 updates (#2027)
---
updated-dependencies:
- dependency-name: pyarrow
  dependency-version: 23.0.1
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: idna
  dependency-version: '3.15'
  dependency-type: indirect
  dependency-group: uv
- dependency-name: starlette
  dependency-version: 1.0.1
  dependency-type: indirect
  dependency-group: uv
- dependency-name: starlette
  dependency-version: 1.0.1
  dependency-type: indirect
  dependency-group: uv
- dependency-name: urllib3
  dependency-version: 2.7.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: python-multipart
  dependency-version: 0.0.27
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: idna
  dependency-version: '3.15'
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 11:13:12 +02:00
Willow LopezandClaude Opus 4.8 50b7eda2ab fix: remove Markdown bold formatting from fact extraction prompt (#2029)
The prompt template used **what**, **when** etc. as field labels.
This Markdown bold syntax leaked into LLM outputs causing non-JSON
responses across all tested models (GPT-4, Ollama models: gemma4,
kimi-k2, llama3.2, qwen3.5, glm-5.1).

Replaced **field** with "field" — same visual emphasis for the model
but no Markdown syntax to confuse JSON output parsing.

Fixes #1138

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-08 11:13:00 +02:00
Evo 78c27bfa74 docs(models): sync gemini + vertexai default models to 3.x matching config.py (#2030)
* docs(models): sync gemini + vertexai default models to 3.x matching config.py

* docs(models): regenerate skills mirror default-model table (gemini+vertexai 3.x)

* docs(models): sync Vertex AI walkthrough + gemini examples to 3.x (complete #2030 scope)

The defaults table fix (#2030) left the env-var examples and Vertex AI
setup walkthrough still handing users the retired gemini-2.0-flash-001
(404 on Vertex) and stale gemini-2.0-flash. Sync the prose surface:
- Vertex AI examples + google/ prefix note -> gemini-3.1-flash-lite (vertexai default)
- Gemini AI Studio example -> gemini-3.5-flash (gemini default)
Regenerated the CI-enforced skills mirror.
2026-06-08 11:12:37 +02:00
Evo b07392c97c docs(configuration): document shared cohere/litellm fallback API-key aliases (#2031)
* docs(configuration): document shared HINDSIGHT_API_COHERE_API_KEY / LITELLM_API_BASE / LITELLM_API_KEY fallback aliases

* docs(configuration): document shared HINDSIGHT_API_COHERE_API_KEY / LITELLM_API_BASE / LITELLM_API_KEY fallback aliases
2026-06-08 11:12:16 +02:00
Evo 727d3214cd docs(models): flag Fireworks AI Batch API support in the capabilities table (#2036)
FireworksLLM overrides supports_batch_api()->True (fireworks_llm.py:106),
and provider=="fireworks" dispatches to FireworksLLM (llm_wrapper.py:424),
but the base OpenAICompatibleLLM grants batch only to openai/groq
(openai_compatible_llm.py:1236) so the override is load-bearing. The
capabilities matrix in llmProviders.json was missing the fireworks
batchApi flag, rendering it as '-' (not supported) and understating the
provider. Regenerated the CI-enforced skills mirror (models.md).
2026-06-08 11:11:58 +02:00
Evo 3346363d2f fix(transfer): include mental_model_history count in import-bank CLI summary (#2032) 2026-06-08 11:11:51 +02:00
zwcf5200 f62500193f fix(trace): preserve RRF source ranks (#2040) 2026-06-08 11:07:32 +02:00
Nicolò Boschi e23e7ca909 release(opencode): v0.2.2 2026-06-08 11:02:38 +02:00
Evo e68d325830 fix(opencode): drop non-function export from plugin entry (#2028) (#2038)
OpenCode >=1.16 iterates every plugin-entry export and throws on any
non-function value; the re-exported DEFAULT_HINDSIGHT_API_URL string
bricked plugin load. Drop it from the entry (still exported from
./config) and add a regression test that the entry is function-only.
2026-06-08 10:52:14 +02:00
Evo 3c8ca47dda fix(reranker): make litellm-sdk reranker api_key optional for Bedrock IAM auth (#2043) 2026-06-08 10:45:33 +02:00
Evo 454069af4d docs(claude-code): correct enableKnowledgeTools default (false→true) and disabled-behavior after #1999 (#2044) 2026-06-08 10:45:11 +02:00
Nicolò Boschi 9622747759 release(superagent): v0.1.0 2026-06-08 10:44:56 +02:00
Nicolò Boschi 854d0a6283 fix(release): register superagent in changelog generator 2026-06-08 10:44:37 +02:00
Nicolò Boschi 36fd445003 release(claude-agent-sdk): v0.1.0 2026-06-08 10:38:53 +02:00
Nicolò Boschi 568fcea422 fix(release): register claude-agent-sdk in changelog generator 2026-06-08 10:38:53 +02:00
Evo c1089698b5 docs(api): document the progress snapshot + include_payload on the operation status endpoint (#2037)
PR #2013 added a durable progress snapshot (OperationProgress: stage/at/
processed/total/detail) plus an updated_at heartbeat and an include_payload
query param yielding task_payload to GET .../operations/{operation_id}, but
the 'Get operation status' docs had no response-field prose for any of them
(the example even passes include_payload without explaining it). Added a
response-fields subsection sourced from http.py. Regenerated the skills mirror.
2026-06-08 10:35:52 +02:00
b708302187 feat(integrations): add Superagent safety middleware (#1128)
* feat(integrations): add Superagent safety middleware for Hindsight memory

Adds hindsight-superagent integration that wraps Hindsight retain/recall/reflect
with Superagent Guard (prompt injection detection) and Redact (PII removal).

- SafeHindsight middleware class with configurable guard + redact pipeline
- Global configure() / per-instance config with env var fallbacks
- CI job and release script entry
- 54 unit tests + 10 e2e tests (all passing)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(superagent): default to Hindsight Cloud URL when no URL is configured

Matches the pattern used by all other integrations — falls back to
https://api.hindsight.vectorize.io instead of erroring.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(superagent): require superagent_api_key, update README defaults

- resolve_safety_client now raises HindsightError if no API key is
  provided, matching actual safety-agent behavior (create_client()
  requires a key)
- README: document superagent_api_key as required, hindsight_api_url
  defaults to Hindsight Cloud URL

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(superagent): disable broken fallback by default, add env var key resolution

The safety-agent SDK's default fallback endpoint (superagent.sh/api/fallback)
returns a 307 redirect that httpx doesn't follow for POST requests, causing
all guard() calls to fail on cold starts. This change:

- Defaults enable_fallback=False so the primary Cloud Run endpoint is used
  directly (60s timeout is sufficient)
- Exposes enable_fallback and fallback_timeout in config/SafeHindsight for
  users who want to opt back in
- Adds os.environ fallback for SUPERAGENT_API_KEY in resolve_safety_client
  so it works without calling configure() first
- Fixes e2e redact test that was blocked by guard on recall query

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(superagent): require explicit guard_model, increase client timeout

Superagent's hosted guard endpoints (Cloud Run Ollama) currently serve
empty model lists, making the default superagent/guard-1.7b unusable.
Update all examples to use guard_model="openai/gpt-4o-mini" and document
the self-hosting alternative. Increase Hindsight client timeout from 30s
to 120s to accommodate reflect's server-side LLM call.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(superagent): disable guard on retain, fix e2e tests for OpenAI guard

General-purpose LLMs (gpt-4o-mini) over-classify PII content as security
violations, blocking retain before redact runs. Disable guard on retain
in all examples and default test helper. Fix e2e tests to use explicit
guard_model and OpenAI provider instead of broken hosted endpoints.

All 10 e2e tests now pass against live APIs.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(superagent): switch guard/redact model to gpt-4.1-nano

gpt-4.1-nano correctly distinguishes prompt injection from legitimate
content (including PII), eliminating the need to disable guard on retain.
Re-enables full Guard → Redact → Retain pipeline.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(superagent): add typed return values and py.typed marker

Replace Any return types on recall() and reflect() with
RecallResponse and ReflectResponse from hindsight-client.
Add py.typed marker for PEP 561 type checker support.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style(superagent): fix ruff line-length formatting in _client.py

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(superagent): add enable_redact_on_recall + lazy SafetyClient

Two gaps surfaced by code review:

1. `enable_redact_on_recall` was missing.  Guard was configurable on every
   op (retain/recall/reflect) but redact was wired only into retain.  A
   memory like "John's SSN is 123-45-6789" stored from a non-safe path
   would come back verbatim through `recall()`.  Added the option to
   redact each result's text on the read path.

   Default is False rather than True because every result triggers its own
   redact call (N results → N round-trips), unlike retain which is always 1
   call.  Callers who care about read-path PII opt in.

2. SafetyClient was resolved eagerly in `SafeHindsight.__init__`, raising
   if SUPERAGENT_API_KEY was missing even when every safety hook was
   disabled.  Moved resolution behind a `_get_safety()` getter that
   constructs on first guard/redact call.  Explicit `safety_client=` still
   wins and is stored directly, so the "supply your own client" path is
   unchanged.

Tests: 62 pass (56 original + 3 redact-on-recall + 3 lazy-resolution).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(superagent): address review-agent findings — env fallback, race, concurrency, scope

Addresses the 1 blocker + 8 should-fixes from the review-agent pass.

Blocker:
- resolve_hindsight_client() now reads HINDSIGHT_API_KEY env directly.  The
  base hindsight_client.Hindsight doesn't fall back to the env var on its
  own, so the constructor-only path (no prior configure() call) was silently
  dropping the key.  Fix: read os.environ.get(HINDSIGHT_API_KEY_ENV) as the
  third precedence step after explicit api_key and config.api_key.

Should-fix:
- Safety client config is now snapshotted at __init__ via snapshot_safety_config()
  and built lazily via build_safety_client() on first guard/redact call.
  A later configure() call cannot silently change what an already-constructed
  SafeHindsight will see.
- Redact-on-recall (and the new retain_batch / redact-on-reflect paths) run
  under an asyncio.Semaphore bounded by `redact_concurrency` (default 5).
  Wide recalls no longer stampede the Superagent rate limit.
- Added `enable_redact_on_reflect` — reflect's synthesised text is also LLM
  output derived from possibly-PII memories, so the same opt-in shape as
  redact-on-recall applies.  Off by default.
- Added `SafeHindsight.retain_batch(items)` wrapping aretain_batch with
  per-item guard + redact under the concurrency cap.  Any item's GuardBlocked
  aborts the whole batch before any store.
- Added `aclose()` + async context manager.  Closes owned underlying clients
  (Hindsight, SafetyClient) but leaves caller-passed clients alone.
- Pinned safety-agent to >=0.1.5,<0.2.0 and hindsight-client to >=0.4.0,<1.0
  so a pre-1.0 minor upstream bump can't silently change the API.
- Switched config-resolution precedence from `or`-chains to `_kw()` helper
  using `is not None`.  Explicit empty list / 0 / False kwargs now override
  global config instead of being treated as "unset".
- Tag merge in retain() now uses `dict.fromkeys(...)` instead of `set(...)`
  so order is preserved (call-tags first, then default tags, deduped).

E2E tests:
- TestE2EGuard block tests now actually assert that Guard blocks (with 3
  retries to absorb model variance).  Previously they silently passed if
  Guard returned "allow" — defeating the purpose.
- Same fix for the bare-Superagent `test_guard_blocks_injection`.
- Added E2E coverage for redact-on-recall, redact-on-reflect, retain_batch,
  and global-config-vs-per-instance-override precedence.

Unit tests:
- 15 new unit tests across 5 new test classes: TestSafetyConfigSnapshot,
  TestRedactConcurrencyCap, TestRedactOnReflect, TestRetainBatch,
  TestLifecycle, TestTagMergeOrder, TestEnvFallback.  All passing; total
  77 unit tests up from 62.

README updated with new options, lazy-resolution clarification, batch and
lifecycle sections.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(superagent): round-3 review-agent findings — E2E rigor, validation, observability

Addresses 5 should-fixes, 2 nits, and 1 question from the round-3 review pass.

E2E rigor (should-fix):
- test_redact_strips_pii_from_stored_memory: previously passed silently if
  recall returned no results.  Now polls via _recall_until_nonempty() so
  empty results fail the test.  Same polling helper applied to every E2E
  that retains-then-recalls (redact-on-recall, redact-on-reflect,
  retain_batch, config precedence) so a non-indexed retain no longer
  silently turns an assertion into a non-assertion.
- test_recall_clean_query / test_reflect_clean_query: now assert the
  stored memory's content actually surfaces in recall/reflect output,
  not just that the response shape is valid.
- cleanup_banks fixture: extended suffix list to include every test class's
  bank (-redact-recall, -redact-reflect, -batch, -precedence) so the new
  E2Es don't leak banks.

Code correctness (should-fix):
- Validate safety_concurrency >= 1 in both SafeHindsight.__init__ and
  configure() — asyncio.Semaphore(0) would deadlock _redact_many() and
  the guard-batching path in retain_batch.  Raises ValueError early.
- Expand retain_batch to pass through every per-item field
  Hindsight.aretain_batch supports (metadata, document_id, entities,
  observation_scopes, strategy) and accept top-level document_id /
  document_tags kwargs.  Previous narrow surface forced callers to fall
  back to the raw client for any of those fields.

Naming + docs (nit):
- Rename `redact_concurrency` → `safety_concurrency`.  The same cap
  bounds both redact-many and the guard-batching loop in retain_batch,
  so the name "redact-only" was misleading.  Public kwarg, config field,
  and internal attr all renamed; tests + README updated.
- Align README requirements list with pyproject bounds: safety-agent
  >=0.1.5,<0.2.0 and hindsight-client >=0.4.0,<1.0.

Observability (question → resolved):
- Add `on_guard(scope, result)` callback invoked for every guard verdict
  (pass and block) so callers can log/observe non-block decisions without
  changing core flow.  Scope is one of "retain"/"recall"/"reflect"/
  "retain_batch".  Sync or async callable accepted; async is awaited.
  Callback fires before GuardBlockedError raises on block, preserving
  observability for the block path too.

Tests added: 12 new across TestSafetyConcurrencyValidation,
TestOnGuardCallback, TestRetainBatchFieldPassthrough.  Total: 87 unit
tests (was 77 → +10 net after the renames).  All passing.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(superagent): round-4 polish — update_mode, retain_async, on_guard error containment

Addresses 2 should-fixes and 1 nit from the round-4 review.

retain_batch surface (should-fix):
- Added "update_mode" to _BATCH_PASSTHROUGH_KEYS.  Hindsight.aretain_batch
  reads item.get("update_mode") per item, so dropping it forced callers
  who wanted controlled upserts to fall back to the raw client.
- Added top-level `retain_async: bool = False` kwarg.  Hindsight supports
  background-processing the batch after the safety pipeline is done; the
  wrapper now exposes that knob.  Guard + Redact still run synchronously
  before the call returns — only the underlying store is deferred.  When
  the default False is used, the kwarg isn't forwarded so the client's own
  default wins.

on_guard error containment (nit):
- The callback is documented as observability "without changing the core
  flow," but a raised exception inside the callback previously took down
  the memory op.  Wrapped the call in try/except with a WARNING log so
  observability failures stay observable instead of fatal.  The log
  includes the scope and the exception type/message so an operator can
  spot a misbehaving callback.  Block-path behaviour is unaffected — if
  Guard says block, GuardBlockedError still raises after the callback
  attempt.

Tests: 93 unit tests pass (was 87; +6 net).  New cases cover update_mode
per-item passthrough, retain_async forwarding (and the don't-forward-on-
default case), sync and async on_guard exception containment, and that
a callback exception doesn't suppress a real block verdict.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* test(superagent): make E2E suite merge-clean — natural-language anchors, lifecycle

Live E2E run with the Superagent key surfaced two reproducible failures
plus aiohttp connector leaks.  Fixes:

1. test_redact_strips_pii_from_stored_memory — previously queried for
   "What is Bob's contact info?", which deterministically misses after
   redact strips Bob's name and email from the stored content.  A first
   attempt added a synthetic canary ("redact-pii-canary alpha bravo")
   alongside the PII, but Hindsight's fact extraction treats opaque
   identifier phrases as noise and drops them, so the canary itself
   didn't surface in recall either.  Fix is to use natural-language
   project context ("Project Phoenix client onboarding") as the anchor
   — fact extraction materialises it as a real fact, vector search
   handles it cleanly, and the assertion verifies (a) the anchor is
   retrievable and (b) the PII is absent from the result.

2. test_redact_on_reflect_scrubs_synthesis — same root cause, same fix.
   Anchor on "Project Tango payment notes" instead of a synthetic
   canary or PII-laden query.  The credit card sits secondary in the
   memory but isn't relied on for retrieval.

3. Unclosed aiohttp ClientSession / TCPConnector warnings — every test
   instantiated a SafeHindsight via _make_client() but never called
   aclose().  Added an autouse fixture that tracks every safe created
   via _make_client() and aclose()s them on test teardown.  Idempotent;
   exceptions during cleanup are swallowed so they don't mask the
   test's own result.

Result: 14/14 E2E pass in 74s (down from 127s due to fewer rerun
attempts on the previously-failing paths) with no unclosed-session
warnings.  93/93 unit tests still pass.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* style(superagent): apply ruff format (fixes verify-generated-files CI)

Same formatter drift as the other integrations: ruff check passed but ruff
format (run by the verify-generated-files job via scripts/hooks/lint.sh)
reflows manually-wrapped lines that fit within 120 cols. Formatting only —
no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* test(superagent): bucket E2E as requires_real_llm; PR CI runs deterministic only

Mark the live E2E suite (real Superagent Guard/Redact + OpenAI + Hindsight)
with a module-level requires_real_llm marker, registered in pyproject,
mirroring the core test split from #1469. The test-superagent-integration job
now runs -m "not requires_real_llm" (deterministic bucket: 93 tests); the
real-LLM bucket (14 tests) is selectable via -m requires_real_llm.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* test(superagent): add deterministic retain->recall->reflect round-trip (mock bucket)

Drives SafeHindsight end to end with mocked Hindsight + Superagent clients,
asserting guard/redact-then-forward across all three ops — the in-CI / no-keys
analog of the live round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore(superagent): remove dead resolve_safety_client

resolve_safety_client at _client.py:87 was a convenience wrapper around
snapshot_safety_config + build_safety_client, with a docstring saying
"kept for backwards compatibility — combines snapshot + build into one
call". As reviewer (benfrank241) flagged on PR #1128: there's nothing
to be backwards compatible with — this is a new package. The middleware
(SafeHindsight) uses snapshot_safety_config + build_safety_client
directly. The function had no real callers.

Drop:
- The function itself from _client.py.
- TestResolveSafetyClient class from tests/test_client.py (its 6 tests
  only exercised the dead wrapper).
- The corresponding import.

test_middleware.py::test_unsafe_path_does_not_resolve_safety_client
stays — the "resolve" there is a generic verb describing whether the
middleware needs to construct a safety client at all, not a reference
to the deleted function. That test still verifies the lazy-construction
semantics it always did.

Test suite: 88 passed, 14 skipped (down from 88+6 = 94 passed; the 6
removed were the wrapper-only tests). Middleware coverage unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore(superagent): ruff format/check fixes for verify-generated-files CI

verify-generated-files flagged _client.py drift (2 trailing blank
lines after the resolve_safety_client removal) plus 3 additional
small lint findings ruff check could autofix. Running the full
ruff format + ruff check --fix pipeline brings the diff to zero
against what CI expects.

No behaviour changes; format-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: DK09876 <[email protected]>
2026-06-05 16:45:34 -04:00
Ben 18c45c9d01 release(opencode): v0.2.1 2026-06-05 16:44:30 -04:00
DK09876andClaude Opus 4.7 06f36b8b25 fix(opencode): default to Hindsight Cloud + gated live E2E (#1915)
* fix(opencode): default to Hindsight Cloud + gated live E2E

Aligns OpenCode with the cloud-default convention adopted across the
Python integrations (LangGraph, Haystack, OpenAI Agents, LlamaIndex,
AutoGen).

Changes:

- config.ts: introduce DEFAULT_HINDSIGHT_API_URL =
  "https://api.hindsight.vectorize.io". Set DEFAULTS.hindsightApiUrl to
  it so the plugin works out-of-the-box against Hindsight Cloud (API key
  via HINDSIGHT_API_TOKEN). Self-hosters override hindsightApiUrl. Also
  re-export the constant from index.ts.

- index.ts: drop the "No API URL configured" branch that returned empty
  hooks. The URL always resolves now (default = Cloud), so the plugin
  always returns its full tool + hook surface. Requests fail at call
  time with a clear server error if no key is configured against Cloud,
  matching the framework's goal-5 contract ("API key not required at
  construction; fails at call time if missing").

- tools.ts: add an index signature to HindsightTools so the object is
  assignable to OpenCode's Hooks.tool (Record<string, ToolDefinition>)
  without losing the three concrete keys. Fixes a pre-existing dts
  build error that was previously masked by the now-removed empty-hooks
  return branch.

- README.md: restructure Quick Start so Cloud is the primary path
  ("enable plugin + set HINDSIGHT_API_TOKEN"); move self-hosted under a
  secondary heading; update the env-var table to show the new default.

- e2e.test.ts (new): gated live test (skipped unless
  HINDSIGHT_LIVE_E2E=1) covering the three contract surfaces — agent
  tool path (retain → server-side extraction → recall), session.idle
  auto-retain, session.created + system.transform inject. TS equivalent
  of the `requires_real_llm` pytest marker used by the Python
  integrations. Exposed as `npm run test:e2e`.

- plugin.test.ts: replace the "returns empty hooks when no URL" test
  with "defaults to Hindsight Cloud" — asserts the client is constructed
  with DEFAULT_HINDSIGHT_API_URL and the full hook surface is returned.

- config.test.ts + test-helpers.ts: update default-value expectations to
  the new cloud-default constant.

- package.json: version 0.2.0 → 0.2.1; add `test:e2e` script.

Verification:
- Deterministic vitest: 6 files / 101 tests pass, 1 file / 3 tests
  skipped (the gated E2E).
- Live vitest (HINDSIGHT_LIVE_E2E=1, against a local Hindsight server):
  7 files / 104 tests pass.
- `npx tsc --noEmit`: clean.
- `npm run build` (tsup): ESM + DTS both succeed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(opencode): reword 'Hindsight Cloud' in test files for OSS-clean (V2 audit)

V2 audit (2026-06-02) flagged two 'Hindsight Cloud' strings in TS test
files under a strict reading of Goal-4 (which says shipped source — .py
and .ts — should not name the cloud product):

- src/e2e.test.ts:14 (file-header comment): 'For Hindsight Cloud:
  HINDSIGHT_API_TOKEN' → 'When pointing at the hosted backend:
  HINDSIGHT_API_TOKEN'
- src/plugin.test.ts:44 (test description): 'defaults to Hindsight Cloud
  when no API URL' → 'defaults to the hosted backend URL when no API URL'

Test behaviour unchanged. The README and PR descriptions can still
name the product.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* test(opencode): pass HINDSIGHT_API_TOKEN to live e2e direct client

The live e2e suite's direct (non-plugin) HindsightClient was constructed
with only { baseUrl: URL }, no apiKey. Against `127.0.0.1:8888` that's
fine — local has no auth. Against `api.hindsight.vectorize.io` the test's
own retain/recall/deleteBank calls 401, masking the fact that the plugin
path itself works against Cloud.

The plugin already reads HINDSIGHT_API_TOKEN from env via its config
resolution. Have the test mirror it: when TOKEN is present, construct
with apiKey. When absent (local-only run), keep the previous shape.

Verified:
- HINDSIGHT_LIVE_E2E=1 against LOCAL (no token):     104/104 pass
- HINDSIGHT_LIVE_E2E=1 against CLOUD (with token):   104/104 pass
- npm test deterministic (no env):                    101/101 + 3 skipped

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore(opencode): prettier format README + e2e.test.ts

verify-generated-files CI flagged drift in:
- hindsight-integrations/opencode/README.md
- hindsight-integrations/opencode/src/e2e.test.ts

Both are pure prettier formatting (line wrapping in README, single
quoted -> double quoted spacing in e2e.test.ts). Running
`npx prettier --write` brings the diff to zero.

No behaviour changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore(opencode): sync openapi.json with main

Branch carried an older snapshot of hindsight-docs/static/openapi.json
that pre-dated main's addition of OperationProgress. Re-checkout main's
openapi.json so check-openapi-compatibility passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-05 16:41:29 -04:00
Ben a74e5e6b5a release(openai-agents): v0.1.2 2026-06-05 16:41:11 -04:00
c01fc12f7e fix(openai-agents): default to Cloud + gated E2E + requires_real_llm bucketing (#1866)
* fix(openai-agents): default to Cloud without configure(); add gated E2E + bucketing

- resolve_client() now falls back to DEFAULT_HINDSIGHT_API_URL and the
  HINDSIGHT_API_KEY env var when configure() was never called (it previously
  raised). Updates the tools + memory_instructions raise-tests to assert the
  cloud-default + env-key behavior. Satisfies the "default to Cloud" goal.
- Add a gated tests/test_e2e.py covering retain/recall/reflect via
  await tool.on_invoke_tool(...) and memory_instructions(), all against a live
  Hindsight server. Marked requires_real_llm; register the marker in pyproject;
  the test-openai-agents-integration CI job now runs the deterministic bucket
  (-m "not requires_real_llm").
- Fix version drift: _version.py was "0.1.0" while pyproject said "0.1.1".
  Sync to 0.1.1 + update the User-Agent assertions in test_tools.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* ci(openai-agents): wire test-openai-agents-integration into aggregate-gate

Audit finding (2026-06-02): the test-openai-agents-integration job is
defined (test.yml L3019) and runs successfully, but is missing from the
report-pr-status job's `needs:` list. That means a failure of this
specific integration job does not block the aggregate pass on
pull_request_review. Pre-existing oversight — the omission predates this
PR — but it's worth closing now so the OpenAI Agents integration's CI
matters for merge gating.

One-line addition: add `- test-openai-agents-integration` to the needs
list, grouped with the other Python integrations.

Verification: YAML parses; no other change needed — the job definition
itself was already correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore(openai-agents): sync openapi.json with main

Branch carried an older snapshot of hindsight-docs/static/openapi.json
that pre-dated main's addition of OperationProgress. Re-checkout main's
openapi.json so check-openapi-compatibility passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: DK09876 <[email protected]>
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
Co-authored-by: Ben <[email protected]>
2026-06-05 16:39:44 -04:00
Ben df7f45e698 release(litellm): v0.5.4 2026-06-05 16:28:45 -04:00
dfe74b1de9 fix(litellm): injection_mode, context manager restore, validation, error consistency (#1711)
* feat(litellm): expand recall/reflect/hindsight_memory APIs and fix default URL

- recall(): add include_entities, trace, recall_tags, recall_tags_match params
  (previously only supported via the callback/enable() path, not the manual API)
- reflect(): add recall_tags, recall_tags_match params (same gap)
- hindsight_memory(): default URL now matches configure()/wrap_openai()/wrap_anthropic()
  instead of hardcoding localhost; add session_id, use_reflect, reflect_context,
  tags, recall_tags, recall_tags_match params
- Document that enable() and HindsightCallback are mutually exclusive injection
  paths to prevent accidental double injection
- Add 17 tests covering all new behaviour

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(litellm): strip hindsight_bank_id from kwargs before LiteLLM call and add sync param to aretain

- hindsight_bank_id kwarg was leaking into LiteLLM as extra_body, causing
  OpenAI 400 errors; now popped in completion(), _wrapped_completion(),
  _wrapped_acompletion() and propagated as bank_id_override throughout
  injection and storage paths
- _inject_memories() accepts bank_id_override to honour per-call bank
  without mutating globals
- _store_conversation() and _store_conversation_from_text() accept
  bank_id_override for consistent per-call storage routing
- _LiteLLMStreamWrapper and _LiteLLMAsyncStreamWrapper carry
  bank_id_override so streamed responses store to the right bank
- aretain() now accepts sync=True, forwarding it to retain()

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(litellm): design review fixes — injection_mode, context manager restore, validation, error consistency

- config.py: remove DEFAULT_BANK_ID footgun (configure() without bank_id now
  leaves bank_id=None; is_configured() and enable() correctly require explicit
  bank_id). Add _restore_config() for atomic state restoration. Add
  budget/recall_tags_match validation in configure() and set_defaults().
  Emit DeprecationWarning for document_id usage.

- __init__.py: _inject_memories() now respects injection_mode
  (PREPEND_USER prepends to last user message; SYSTEM_MESSAGE keeps existing
  behaviour). Wire up defaults.query as fallback recall query. Fix
  ValueError → HindsightError for missing bank_id. hindsight_memory()
  finally block now calls _restore_config() to atomically restore all settings
  (previously lost: sync_storage, tags, recall_tags, recall_tags_match,
  reflect_context, reflect_response_schema). Add _enabled_lock and _debug_lock
  for thread safety on shared mutable state.

- callbacks.py: ValueError → HindsightError in log_pre_api_call and
  async_log_pre_api_call for missing bank_id, consistent with __init__.py.

- tests: update tests that relied on DEFAULT_BANK_ID behaviour; add
  TestValidation, TestInjectionMode, TestQueryField, TestHindsightErrorConsistency,
  TestContextManagerFullRestore (83 tests, all passing).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(litellm): run ruff format and update test_config.py for no-default-bank-id behaviour

- Run ruff format on __init__.py and wrappers.py to match CI lint expectations
- test_config.py: update test_configure_with_no_arguments to assert bank_id is None
  (not DEFAULT_BANK_ID) and rename test_is_configured_true_with_defaults to
  test_is_configured_false_without_explicit_bank_id with corrected assertion,
  matching the removed DEFAULT_BANK_ID footgun

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(litellm): declare hindsight-client dep, add E2E suite, implement set_bank_mission

Addresses PR review blockers and one user-facing should-fix:

1. **hindsight-client missing from dependencies** — the package imports
   `hindsight_client` and `hindsight_client_api` in 11+ places but never
   declared the dep, so `pip install hindsight-litellm` from PyPI raised
   ModuleNotFoundError on any retain/recall/reflect path.  Add explicit
   `hindsight-client>=0.4.0` to project deps.

2. **E2E suite was out-of-tree** — moved the 23-test live-API suite into
   `tests/test_e2e.py` with env-var-based `HINDSIGHT_API_URL` and
   skip-on-missing-keys markers (`requires_hindsight`, `requires_openai`,
   `requires_all`) matching the sibling integrations' layout.  Tests
   collect cleanly; skip when no live server / OpenAI key is available.

3. **set_bank_mission() was documented but never implemented** —
   README.md showed `hindsight_litellm.set_bank_mission(mission=..., name=...)`
   as a public API, but no such function existed.  Implement it as a thin
   wrapper around `Hindsight.create_bank()` that resolves bank_id /
   url / api_key from the configured defaults, with HindsightError on
   missing bank_id or underlying client failure.  Add 4 unit tests.

4. Add `Python :: 3.13` to package classifiers.

Unit tests: 113 passed (was 109, +4 new set_bank_mission tests).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(litellm): dual-injection guard, LRU dedup cache, excluded_models in enable() path

Three correctness should-fixes from the PR review:

1. **Dual-injection footgun guard** — when both enable() and a
   HindsightCallback registered on litellm.callbacks were active,
   memories would be injected twice (once by the monkeypatch, once by
   the callback running inside the original litellm.completion).
   - enable() now scans litellm.callbacks at install time and emits a
     RuntimeWarning if a HindsightCallback is already present.
   - HindsightCallback.log_pre_api_call / async_log_pre_api_call now
     short-circuit when is_enabled() returns True, so registering a
     HindsightCallback after enable() no longer double-injects.

2. **Dedup cache LRU + thread safety** — _recent_hashes was a Set[str]
   without a lock; set.pop() evicted an arbitrary entry rather than the
   oldest, and the cache was mutated from both the sync log_success_event
   and the async executor path with no synchronization. Replace with
   OrderedDict + threading.Lock, move_to_end on hits for true LRU, and
   popitem(last=False) on eviction.

3. **excluded_models honored in enable() monkeypatch path** — the
   excluded_models config was previously only checked by the
   HindsightCallback path; _wrapped_completion / _wrapped_acompletion
   would inject memories on every model regardless. Add an early-out
   that calls the original litellm function untouched when the model
   matches any excluded_models glob.

Unit tests: 119 passed (was 113, +6 new tests covering each fix).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(litellm): close wrapper clients + own one event loop; test hygiene

wrap_openai()/wrap_anthropic() wrappers gain close()/context-manager support
so the cached Hindsight client (and its aiohttp session) is released; this
eliminates the unclosed client-session/connector ResourceWarnings.

Replace the per-call `new_event_loop()` bridges with a single owned per-thread
loop (hindsight_litellm/_async.py), set as the thread's current loop so the
client reuses it and the `asyncio.get_event_loop()` deprecation (which becomes
an error on 3.14) no longer fires from our sync paths. The loop is
deliberately NOT closed in cleanup(): a shared loop closed under a live client
raises "Event loop is closed", so close_loop() is a documented manual-only
shutdown helper.

Test hygiene: add pytest-asyncio to the dev dependency group (fixes the
"Unknown config option: asyncio_mode" warning), close clients in the E2E
fixtures, and add unit tests for wrapper close() and the _async bridge.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* style(litellm): sort _async import before config (ruff I001)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(litellm): own loop in wrap bank-setup + correct loop-lifecycle docs

- ensure_loop() now runs before wrap_openai()/wrap_anthropic() create the
  bank/mission setup client, matching _get_client and the config bank paths
  (no orphaned loop / get_event_loop deprecation on that path).
- Correct stale comments + module docstring that claimed cleanup() closes the
  owned loop — it does not; close_loop() is a documented manual-only helper.
- Convert the flaky context-manager E2E test from a fixed sleep to polling.
- Add unit coverage for wrap bank-setup loop ownership.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* style(litellm): apply ruff format (fixes verify-generated-files CI)

ruff check passed but ruff format (run by the verify-generated-files job via
scripts/hooks/lint.sh) reflows manually-wrapped lines that fit within the
120-col limit. Formatting only — no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* test(litellm): bucket E2E as requires_real_llm; PR CI runs deterministic only

Mark the live E2E suite (real Hindsight + provider calls) with a module-level
requires_real_llm marker, registered in pyproject, mirroring the core test
split from #1469. The test-litellm-integration job now runs
-m "not requires_real_llm" (deterministic bucket: 134 tests); the real-LLM
bucket (23 tests) is selectable via -m requires_real_llm for a dedicated or
nightly job.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* test(litellm): add deterministic full inject-flow test (mock bucket)

Mocks the Hindsight client's recall and spies litellm.completion to assert the
recalled memory is injected into the messages the LLM receives — the in-CI /
no-keys analog of the live enable()/completion tests. Runs in the deterministic
bucket.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(litellm): thread api_key into _get_client on the inject path

Audit finding (2026-06-02): hindsight_litellm/__init__.py:311 constructs the
Hindsight client via _get_client(config.hindsight_api_url) without forwarding
config.api_key. The retain path threads the key correctly via wrappers.py
(L177/355/471), but the recall/reflect injection path doesn't — so Hindsight
Cloud writes succeed while reads return 401 "Authentication failed: API key
required". The earlier review pass missed this because it tested only against
a local self-hosted server; an out-of-session audit ran the user-perspective
driver against api.hindsight.vectorize.io with an hsk_ key and caught the
asymmetry.

Fix: forward config.api_key as the second positional argument. Single-line
behavioral change.

Regression pin: TestInjectionPathPassesApiKey — configures the integration
with a Cloud-shaped URL + key, patches _get_client to capture call args,
runs _inject_memories, asserts the configured key was forwarded. Tolerates
positional and keyword call forms.

Other audit-suggested callsites (587/643/1343/1425) were _inject_memories
invocations, not _get_client; they don't carry api_key directly. wrappers.py,
config.py, and the cached-client paths in HindsightOpenAI / HindsightAnthropic
already pass the key.

Verification:
- Deterministic bucket: 136 pass (135 prior + 1 regression).
- Live bucket: 12 pass / 11 skipped / 0 failed (skips are
  provider-key-conditional, not affected by this change).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(litellm): reword 'Hindsight Cloud' references for OSS-clean (V2 audit)

V2 audit (2026-06-02) caught two 'Hindsight Cloud' literals introduced by
the cloud-injection 401 fix (commit 3083a4a2):

- __init__.py:309 (comment): 'Hindsight Cloud rejects un-keyed recall/reflect'
  → 'the hosted backend rejects un-keyed recall/reflect'
- tests/test_integration.py:1423 (assertion message): 'breaks Hindsight Cloud
  reads' → 'breaks reads against the hosted backend'

Goal-4 (OSS-clean) of the integration-review rubric: shipped .py source
should not name the cloud product. The README and PR descriptions still
can. This restores compliance — behaviour and the regression test pin
itself are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(litellm): forward sync=True to retain() in sync_storage path

When configure(sync_storage=True) was set, _store_conversation() and
_store_conversation_from_text() called the package-level retain() without
passing sync=True. retain()'s own default is sync=False (background daemon
thread), so the storage POST was dispatched off-thread and the function
returned immediately. The 'Stored conversation to bank' INFO log was
emitted before the HTTP request had actually been sent.

In long-lived processes (Jupyter notebooks, the cookbook flow) this was
invisible because the daemon thread had time to complete. In short-lived
processes — a writer CLI that exits after a single completion() call —
the daemon thread was killed at process exit and the POST never landed
on the server. A second process recalling against the same bank a few
seconds later observed zero memories, even with sync_storage=True.

Cross-process drop-in is the most basic real-app pattern users try after
the cookbook, so this silent data loss had to be fixed before merge.

Reproduction (pre-fix):
  Process A: configure(sync_storage=True) + litellm.completion(...)
             → logs "Stored conversation to bank: BANK"
             → process exits
  Wait 10s.
  Process B: Hindsight(...).list_memories(BANK)
             → 0 memories

Post-fix: Process B sees the extracted memories as expected.

Adds two regression tests that mock retain() and assert sync=True is
forwarded in both the non-streamed and streamed sync_storage branches.
Both fail on the prior code; both pass now.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore(litellm): remove dead _debug_lock

_debug_lock at __init__.py:165 was never used — there's no `with _debug_lock:`
anywhere in the codebase and every _last_injection_debug write is unguarded.
Reviewer (benfrank241) flagged this on PR #1711. Drop the unused variable.

threading is still imported (used by _enabled_lock at line 158,
_storage_error_lock at line 1035, and two threading.Thread spawns at 1190 +
1263), so the import stays.

105/105 tests in test_integration.py pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* chore(litellm): sync openapi.json with main

Branch carried an older snapshot of hindsight-docs/static/openapi.json
that pre-dated main's addition of OperationProgress. Re-checkout main's
openapi.json so check-openapi-compatibility passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: DK09876 <[email protected]>
Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-06-05 16:27:58 -04:00
a933a417cd feat(claude-agent-sdk): add Claude Agent SDK integration (#1582)
* feat(claude-agent-sdk): add Claude Agent SDK integration with memory tools and hooks

Adds hindsight-claude-agent-sdk package providing:
- In-process MCP server with retain, recall, and reflect tools
- Automatic memory hooks (auto-recall on prompt, auto-retain on stop)
- Tool output retention via PostToolUse hooks
- Global configuration and per-call overrides
- 74 unit tests, CI job, and release script entry
- Cookbook recipe for docs site

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(claude-agent-sdk): default to Cloud without configure(); add gated E2E + bucketing

- resolve_client() now falls back to DEFAULT_HINDSIGHT_API_URL and the
  HINDSIGHT_API_KEY env var when configure() was never called (it previously
  raised). Updated the tools + hooks unit tests to assert the cloud-default +
  env-key behavior. Satisfies the "default to Cloud" goal for both
  create_hindsight_tools and create_memory_hooks.
- Add a gated tests/test_e2e.py (retain/recall/reflect MCP tools against a live
  Hindsight server, stdlib urllib health check — no requests dep), marked
  requires_real_llm; register the marker; the test-claude-agent-sdk-integration
  CI job now runs the deterministic bucket (-m "not requires_real_llm").

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* test(claude-agent-sdk): assert create_memory_hooks reads HINDSIGHT_API_KEY from env

Mirrors the tools env-key test so hook construction's cloud-default + env-key
path is covered, not just the no-key default.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: DK09876 <[email protected]>
2026-06-05 16:23:15 -04:00
Ben 05602730e8 release(langgraph): v0.2.0 2026-06-05 16:17:56 -04:00
b67e813a83 LangGraph: add memory_instructions, fix nodes, remove BaseStore (#1673)
* docs: add langgraph.py example snippets for integration docs

Adds embeddable code snippets covering all three LangGraph integration
patterns: tools (ReAct agent), memory nodes, BaseStore, and constructor
options. Follows the same [docs:section] pattern as ai-sdk.ts.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* LangGraph integration: add memory_instructions, fix nodes, remove BaseStore

- Add memory_instructions() for standalone LangChain use without a graph
- Add recall_types, recall_include_entities to create_recall_node()
- Add metadata, document_id to create_retain_node()
- Nodes now raise HindsightError instead of silently swallowing errors
- Remove HindsightStore (BaseStore adapter) — leaky KV abstraction over
  semantic memory (get unreliable, delete no-op, list session-scoped)
- Update README: cloud-first examples, add memory_instructions section
- Update docs example: replace base-store with memory-instructions snippet
- Fix pre-existing test failures (user_agent mock mismatch)
- 52 unit tests pass, 13 E2E tests pass

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style(langgraph): run ruff format on tools.py

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* docs(langgraph): keep cloud product unnamed in module docstring

The docstring example said "Uses Hindsight Cloud by default" — names the
cloud product in OSS source.  Per the integration review's OSS-clean rule,
the cloud should be reachable by overriding hindsight_api_url but not
explicitly named in core code.  Rephrased to "Uses the default API URL"
and "Or point at a different instance".

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* chore(langgraph): address PR review polish items

- __version__ now derived from package metadata (was stale 0.1.0 vs pyproject 0.1.2)
- pyproject description no longer references the removed store adapter
- create_hindsight_tools return type tightened from `list` to `list[BaseTool]`
- memory_instructions docstring now documents the deliberate silent-fallback
  on Hindsight error (vs nodes which raise) — load-bearing API contract
- create_retain_node docstring now notes ToolMessage / FunctionMessage
  content is intentionally skipped

No behaviour change; 52/52 unit tests still pass.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(langgraph): default to Cloud without configure() + add gated E2E suite

resolve_client() now falls back to DEFAULT_HINDSIGHT_API_URL and the
HINDSIGHT_API_KEY env var when configure() was never called, matching the
Superagent pattern and satisfying the "default to Cloud" goal. Previously
create_hindsight_tools(bank_id=...) raised without an explicit URL/config.

Also add an in-tree, pytest-gated tests/test_e2e.py covering the tools,
graph-node, and memory_instructions patterns (skips when no live Hindsight),
update unit tests to assert the Cloud-default behavior, and close the
Hindsight clients in the manual smoke scripts to avoid unclosed-session
warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(langgraph): drop "Hindsight Cloud" product name from tools docstring

Keeps the OSS source product-agnostic — cloud naming belongs in the
cookbook/blog, not the package. Behavior unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* test(langgraph): bucket E2E as requires_real_llm

Mark the live E2E suite (drives a live Hindsight server) with a module-level
requires_real_llm marker, registered in pyproject, mirroring the core test
split from #1469. Deterministic bucket (-m "not requires_real_llm") = 53 unit
tests; real-LLM bucket (-m requires_real_llm) = 6 E2E.

Note: there is no test-langgraph-integration CI job yet, so this marker is not
wired into CI; adding that job is tracked as a follow-up in the review log.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* test(langgraph): add deterministic compiled-graph flow test (mock bucket)

Wires a real compiled StateGraph (recall -> agent -> retain) backed by a mocked
Hindsight client, asserting the recall node injects memory and the retain node
stores the human turn — the in-CI / no-keys analog of the live graph test.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* ci(langgraph): add test-langgraph-integration job + 3 supporting wiring places

Audit finding (2026-06-02): hindsight-langgraph has zero CI presence in
.github/workflows/test.yml — no detect-changes output, no path filter, no
job definition, no aggregate-gate entry. The prior review-log called this
PR MERGE-READY based on "green at time of audit"; the audit caught that
green was consistent with "no job exists to fail" — changes to the package
silently bypassed CI.

This commit adds the missing wiring, mirroring the AutoGen #1868 pattern
that added the same scaffold for that package's integration job:

  1. L41   detect-changes output: integrations-langgraph
  2. L126  path filter:           hindsight-integrations/langgraph/**
  3. L2914 job def:               test-langgraph-integration
           - timeout-minutes: 30 (matches autogen/openai-agents)
           - runs uv build + uv sync --frozen + pytest with the
             `-m "not requires_real_llm"` exclusion so the deterministic
             bucket runs in PR CI while the live bucket is reserved for
             the dedicated/nightly job (the standing convention from
             PR #1469).
  4. L3911 aggregate gate entry:  test-langgraph-integration

Verification:
- YAML parses (python -c 'yaml.safe_load(...)').
- Deterministic bucket unchanged: 55 pass / 6 deselected.

The PR's existing integration code is unchanged — this is purely test-yml
scaffolding.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: DK09876 <[email protected]>
2026-06-05 16:16:48 -04:00
Ben 2e011d279c blog: How Hindsight Learns — A Deep Dive Into Mental Models (#2021)
* blog: Mental Models in Hindsight — A Code-Level Deep Dive

Definitive technical reference for the mental-models feature. Every
claim is grounded in the docs or the implementation, with file paths
and line numbers cited inline.
2026-06-05 15:18:30 -04:00
Nicolò Boschi c94935bfa2 feat(operations): durable progress snapshot for consolidation and batch retain (#2013)
* feat(operations): durable progress snapshot for consolidation and batch retain

Long-running consolidation could look identical whether healthy or stuck:
updated_at was only touched on claim/complete, with no mid-run progress, so
operators couldn't tell a slow job from a frozen one without DB access (#1840).

Add a best-effort heartbeat that writes a coarse {stage, processed, total,
detail} snapshot into async_operations.result_metadata (top-level jsonb merge so
sibling keys survive) and bumps updated_at, at phase/batch boundaries:
- consolidation: scanning -> processing_batch (per round, with observation
  counters) -> refreshing_mental_models
- batch retain: processing_sub_batch per sub-batch (split loop + small-batch path)
Each call mirrors the same stage into the existing set_stage() so live worker
logs and the durable row tell one story.

Surface it as a typed `progress` field on the operation list/status API
(OperationProgress model); null when no snapshot was recorded. Regenerate
OpenAPI spec + Python/TS/Rust/Go clients.

Scope is visibility only: no staleness classification or auto-kill.

Tests: helper merge-without-clobber + updated_at bump, API surfacing on
get/list, null-when-absent, and real-run wiring for consolidation (processed
advances to total) and batch retain.

* feat(control-plane): show operation progress snapshot in operations view

Surface the new `progress` field (stage + processed/total + per-phase counters)
that the dataplane writes for running consolidation/batch-retain operations.

- Type `progress` through api.ts (listOperations + getOperationStatus) via a
  shared OperationProgress interface.
- bank-operations-view: render a compact stage + processed/total bar under the
  status badge on processing rows, and a full progress block (with detail
  counters) in the operation details dialog. Refreshes via the existing poll.
- Add the `field.progress` label to all locale message files.

UI half of #1840; pairs with the dataplane progress snapshot.

* fix(operations): make retain progress reach total on completion; hide on terminal ops

A finished single-sub-batch retain was frozen at a pre-run "processing_sub_batch
0/1" snapshot: it was written *before* the sub-batch ran and never updated, so a
completed operation looked stuck. The control-plane details dialog also rendered
that leftover heartbeat regardless of status, so a completed op showed an
in-progress bar.

- Write the retain progress snapshot *after* each sub-batch commits (processed=i
  for the split loop, 1/1 for the small-batch path), so the last snapshot reaches
  total/total and reflects completion instead of a stale pre-run count.
- Control plane: only render the progress section while status is "processing";
  for terminal operations the status badge + completed_at are the source of truth.

Update the retain progress test to assert the snapshot reaches total/total and
the durable row reflects completion.

* fix(operations): per-LLM-batch consolidation progress + live heartbeat in UI

Consolidation progress was written only at the outer DB-fetch round boundary, but
a whole batch of memories is processed inside a single round's LLM dispatch — so
the snapshot sat at "scanning 0/N" for the entire (often minutes-long) LLM phase
and only jumped at the very end, looking stuck even while healthy.

- Write the snapshot per LLM batch using the cumulative processed count that the
  per-batch log already tracks, with cumulative observation counters in detail.
  processed now climbs 8/42, 16/42, … as batches commit. Drop the now-redundant
  round-boundary write.
- Control plane: show a live "last heartbeat · Ns ago" line under the progress
  bar, ticking every second (only while an operation is processing) so a frozen
  heartbeat on an active job is visible at a glance. Add heartbeat/lastHeartbeat
  labels to all locales.

* fix(operations): clearer consolidation stage, compact progress row, faster poll

Address operator-feedback on the progress UI:
- Collapse consolidation's "scanning" + "processing_batch" into one self-explanatory
  "consolidating" stage that advances 0/N -> N/N, instead of an opaque scan->process
  hop nobody could interpret.
- Control plane: render the in-row progress as a single compact line (bar + count +
  heartbeat age) so the status column no longer stacks three rows; the full breakdown
  (stage, counters, labelled heartbeat) stays in the details dialog.
- Poll the operations list every 2s while something is processing (was a flat 5s) so
  the bar and heartbeat feel live, backing off to 5s when everything is terminal.

* feat(operations): chunk-level retain progress; cap consolidation total; drop detail badges

- Retain now reports "storing N/total chunks" from the streaming pipeline as each
  consumer batch commits (threaded via a progress_callback so the engine stays
  decoupled and operation_id/total_chunks are already in scope). Replaces the coarse
  per-sub-batch tick — a long document now shows chunks committing live.
- Consolidation: treat total as an estimate that grows with processed
  (max(total_count, processed)) so the bar never reads >100% (e.g. 58/51) when memories
  are retained mid-run.
- Control plane: drop the per-counter detail badges from the progress dialog (noisy);
  the bar + stage + heartbeat carry the signal.

* feat(control-plane): inline progress + heartbeat on the status badge row

Put the compact progress (bar + count + heartbeat age) on the same line as the
status badge instead of stacking a second row under it, so a processing row reads
"⟳ processing  ▓▓░ 8/42 · 5s" on one line.

* feat(operations): Updated column, fixed-width status, snappy completion flash

Operator-feedback polish on the operations table:
- Add an "Updated" column (relative time, absolute in tooltip). Required surfacing
  updated_at on the operations *list* endpoint (it was only on the detail endpoint);
  regenerated OpenAPI + clients.
- Give the status column a fixed width so the row no longer shifts left when the
  inline progress appears/disappears as an operation starts or finishes.
- Flash a row briefly (emerald on completed, red on failed/cancelled) when it
  transitions to a terminal state, with a 700ms color transition, so a completion
  landing on a poll reads as a deliberate change instead of a silent badge swap.
- Refresh the relative-time clock on every poll so the Updated column stays accurate
  while idle (not just while the per-second heartbeat ticker runs).

* feat(control-plane): label and fix the Actions column width

The Actions column had no header and no fixed width, so it grew when a pending/failed
row's Cancel/Retry button appeared — shifting the whole table. Give it an "Actions"
label (added to all locales) and a fixed 110px width on header and cell so the layout
stays put regardless of which rows show an action button.

* feat(operations): update consolidation total by re-counting instead of clamping

Replace the max(total, processed) clamp (which pinned the bar at 100% once processed
caught the start-of-job estimate) with a real re-count: once processed passes the
initial estimate, report total = processed + still-pending. Guarded so the extra
COUNT only runs after the estimate is exhausted (≈the final batch normally, or
repeatedly only if memories keep arriving mid-run) — no per-batch query in the common
case.

Also explain it in the UI: the consolidation progress section notes that the total is
an estimate from job start and can grow if new memories arrive while it runs.

* fix(control-plane): label file_convert_retain as "Convert File"
2026-06-05 18:11:07 +02:00
Nicolò Boschi e30f8af148 fix(llm): downgrade tool_choice="required" for servers that silently drop it (#2016)
vLLM (--enable-auto-tool-choice), LM Studio and Ollama advertise
tool_choice="required" but silently ignore it: instead of forcing a tool
call they return finish_reason "stop"/"tool_calls" with an EMPTY tool_calls
array and no HTTP error. Reflect's agent loop forces its retrieval tools via
named tool_choice dicts (normalized to "required" + a single filtered tool),
so on these endpoints the agent calls zero tools, synthesis runs with no
retrieval, and reflect answers "I don't have information" even when the bank
holds the answer.

Downgrade "required" to auto (None/omitted) for these self-hosted endpoints
so the model still gets to call a tool. Named dicts already narrow the tools
list to one entry, so forced calls stay practically forced under auto. The
real OpenAI API (no base_url override), llama-server (which honors
"required", per #1179) and cloud providers are left untouched.

Fixes #1877. Same bug class as #1563 (LM Studio) and #1179 (LM Studio +
Qwen), both of which this also resolves.
2026-06-05 17:32:04 +02:00
Nicolò Boschi 4f50034800 fix(init): fail fast when model init blocks instead of hanging forever (#2014)
Model/connection initialization had no wall-clock cap: if embeddings, the
cross-encoder, or LLM verification blocked (e.g. an offline HuggingFace
download or an unreachable provider), `asyncio.gather` in
`MemoryEngine.initialize()` never returned and the daemon hung in a third
state — neither started nor errored. The lazy reranker path
(`CrossEncoderReranker.ensure_initialized()`) had the same problem on the
first request.

Wrap both with `asyncio.wait_for` capped by a new static config
`HINDSIGHT_API_MODEL_INIT_TIMEOUT` (default 300s, generous enough for
first-time model downloads). On timeout, raise a clear RuntimeError that
names the likely cause and points at the env var — no silent fallback.

Fixes #1897
2026-06-05 16:18:02 +02:00
Nicolò Boschi 3b2830c7d8 docs(models): note Groq free tier (8k TPM) is unsuitable for Hindsight (#2015)
Retain reserves max_completion_tokens (~64k) up front, and Groq's free-tier
8k TPM limit counts that reservation at admission, so every retain call is
rejected with HTTP 413 'Request too large' even for a one-line message.
Document that the free tier is unsuitable and a paid tier / other provider
is required. Refs #1573.
2026-06-05 15:54:24 +02:00
Nicolò Boschi c255d35525 fix(reflect): let a fresh mental model short-circuit forced retrieval (no extra LLM call) (#2011)
* fix(reflect): let a fresh mental model short-circuit forced retrieval

Reflect forced the full hierarchical path
search_mental_models -> search_observations -> recall via a named
tool_choice on the first iterations. Because a named tool_choice forbids
the model from emitting `done`, the agent could never answer off a fresh,
directly-relevant mental model — it always paid for the lower layers too
(issue #1971).

Fix: after the forced search_mental_models result, decide deterministically
(no extra LLM call) whether to keep forcing. If the call is low/mid budget
and every retrieved mental model is explicitly fresh (is_stale is False)
with non-empty content, stop forcing from the next iteration on. That
iteration — which happens regardless — now runs under `auto`, so the agent
either answers directly or, having just read the mental model, issues its
own targeted search_observations/recall. Stale, empty, or missing mental
models keep the full forced path; high budget always keeps it.

This reuses the agentic step that already occurs instead of adding a
separate sufficiency-classifier LLM call, so the sufficient path saves two
forced rounds and no path ever adds a round.

* test(reflect): add real-LLM e2e coverage for mental-model short-circuit

Two hs_llm_core end-to-end tests drive the real agent loop (stubbed
search functions, real llm_config) to verify behaviour the deterministic
MockLLM tests cannot:

- fresh + sufficient mental model: the released agent answers off it and
  never calls search_observations/recall (judge-verified grounding);
- stale mental model: no short-circuit, lower layers stay forced, and the
  agent corrects the stale summary using the freshly retrieved raw fact.

The stale case (forcing is deterministic) is used rather than a
"fresh-but-incomplete model retrieves deeper on its own" case, because
whether a released model chooses to dig deeper is model-dependent and not
something the fix guarantees — only release-to-auto is guaranteed.
2026-06-05 15:20:39 +02:00
Nicolò Boschi 7e1145c08a feat(history): move mental-model & observation history into dedicated tables (#2007)
Both histories accumulated in a single JSONB/CLOB `history` column, appended
to on every update. Observations had NO cap at all, so a frequently-reinforced
observation grew until it crossed Postgres's 256MB jsonb limit (SQLSTATE 54000)
and the row got stuck. Mental models capped by entry COUNT (not size) and
rewrote the whole array + TOAST per refresh, defeating HOT updates.

Now one row per change in mental_model_history / observation_history, indexed
on (item, changed_at DESC, id DESC). Each row stores its snapshot as a single
JSONB `content` blob (per-row, so it stays small) plus changed_at; the cap is
enforced at write time as a bounded DELETE of the oldest over-cap rows, for
both histories (new per-observation cap:
HINDSIGHT_API_OBSERVATION_HISTORY_MAX_ENTRIES, default 50).

- migration a7b8c9d0e1f2: create tables, backfill from the JSONB/CLOB arrays
  (PG jsonb_array_elements / Oracle JSON_TABLE), drop the legacy columns
- write paths: insert-then-trim in consolidator (observations) and
  memory_engine (mental models); also stop writing the dropped column in the
  create-observation INSERT
- read paths: get_observation_history / get_mental_model_history read the new
  tables; observation list/get no longer select the column
- export/import: mental_model_history carried (parent keeps a stable id, the
  surrogate id is dropped so the target reassigns it); observation_history is
  derived (observations regenerate with fresh ids on import) and not carried
- tests: deterministic observation-history coverage + MM-history export/import
  round-trip
2026-06-05 15:07:30 +02:00
Nicolò Boschi 75a7c19d6a fix(docker): clear diagnostic for pg0 bind-mount permission failure (#1483) (#2010)
* fix(docker): clear diagnostic for pg0 bind-mount permission failure (#1483)

The standalone image runs rootless (UID 1000). A host bind mount whose
directory isn't owned by UID 1000 — the default on macOS Docker Desktop and
most non-1000 Linux hosts — makes embedded pg0 fail with the opaque
"Permission denied (os error 13)". Auto-chowning the volume would require
running as root, which we deliberately avoid.

Instead:
- Recommend a Docker named volume in the README/installation docs; named
  volumes are seeded with the image's UID-1000 ownership, so they work with
  zero setup and stay rootless.
- Add a pg0 writability pre-check in start-all.sh that prints an actionable
  message (named volume, or --user) and exits cleanly instead of letting pg0
  emit os-error-13. Skipped when an external database is configured.
- Add regression tests for the new check in test-start-all.sh.

* docs(readme): drop bind-mount explanation, keep named-volume fix
2026-06-05 14:39:09 +02:00
Nicolò Boschi 82800ba864 fix(ci): repair zeroentropy embedding tests and regenerate drifted clients (#2009)
* fix(openapi): keep binary upload fields as format:binary; regen spec+clients

The #1982 dep bump (FastAPI 0.136 / Pydantic 2.12) serializes binary upload
fields as OpenAPI-3.1 {"type":"string","contentMediaType":"application/
octet-stream"}. openapi-generator v7.10.0 (generate-clients.sh) does NOT
treat contentMediaType as a file upload, so it regenerated the Files `files`
and document-transfer `file` params as plain strings — silently breaking
multipart upload in the Go/Python/TypeScript clients ([]*os.File -> []string,
StrictBytes -> StrictStr, Blob|File -> string).

generate_openapi.py now post-processes the exported schema to restore the
prior `format: binary` representation (still valid under openapi 3.1.0, and
what the generator understands) for application/octet-stream string fields,
scoped to binary uploads only. Regenerated the spec and clients: the upload
signatures are back to the file-upload form (identical to main); the only
remaining delta vs main is ValidationError dropping its `url` field, a real
Pydantic 2.12 change (error metadata, harmless).

* test(embeddings): give zeroentropy routing mocks a dimension attribute

PR #1670 added post-encode dimension validation to generate_embeddings_batch
— it now reads embeddings_backend.dimension, which the EmbeddingsBackend
Protocol already requires. The pre-existing QueryAwareEmbeddings/
DocumentAwareEmbeddings routing mocks (#1770) omit it, so the two routing
tests started failing with AttributeError on main.

The mocks return single-element vectors, so declare dimension = 1 to satisfy
the Protocol and let validation pass. Pure test fix; no behavior change.

* test(openapi): lock _restore_binary_format binary-upload rewrite

Regression guard for the file-upload break: asserts octet-stream string
fields are rewritten to format:binary (incl. nested/array-item schemas) and
that other content media types are left untouched.
2026-06-05 14:38:44 +02:00
Nicolò Boschi 2860c9ae16 refactor(api): unify lazy bank-create into _ensure_bank_exists, couple to caller txn (#2004)
All bank-scoped write paths lazily create the bank (the FK target) before
their first insert. That logic was duplicated across create_mental_model,
create_webhook, submit_async_retain, and the import paths as a bare
get_or_create_bank_profile + best-effort default-template apply, and it ran
on its own connection — so a freshly-created bank could outlive a write that
ultimately failed.

Introduce a single MemoryEngine._ensure_bank_exists() entry point:
  * Pass conn (with an open transaction) to run the bank INSERT + per-bank
    vector index creation on the caller's connection, so the bank row commits
    or rolls back atomically with the caller's write. Used by
    create_mental_model, create_webhook, and submit_async_retain (whose
    parent+child inserts already share one transaction).
  * Omit conn for paths with no single write transaction to join (retain and
    import write later across many per-document transactions); the bank is
    created on a dedicated connection as before.

The HINDSIGHT_API_DEFAULT_BANK_TEMPLATE hook is best-effort, opens its own
connections, and can itself create pinned models, so it is never run inside
the caller's transaction — it stays a post-commit step, applied only when the
bank was freshly created. Add get_or_create_bank_profile_on_conn() in
bank_utils as the connection-bound variant.

Both get_or_create_bank_profile and its _on_conn variant now return a typed
BankProfileResult dataclass instead of a (profile, created) tuple.

Tests: add txn-rollback atomicity coverage for create_mental_model (a failing
insert rolls the new bank back) and submit_async_retain (new bank rolls back
with the operation rows), plus missing-bank coverage for webhooks and batch
retain. Update test_async_retain_tags to stub _ensure_bank_exists (the method
submit_async_retain now calls).
2026-06-05 12:47:23 +02:00
Nicolò Boschi 049901802f fix(api): add vchord catalogs to search_path for external Postgres (#1351) (#2008)
VectorChord BM25 registers its objects in dedicated schemas
(vchord_bm25 -> bm25_catalog, pg_tokenizer -> tokenizer_catalog). The
BM25 distance operator <&> resolves its operand types via the session
search_path, so a connection that lacks these schemas fails recall with
'type "bm25vector" does not exist' and retain with
'function tokenize(...) does not exist'.

The official vchord-suite Docker image masks this by shipping the
catalogs in search_path; an external Postgres does not. Set the same
search_path on each connection when the vchord text-search backend is
configured. Qualifying the SQL is insufficient: the <&> operator's type
resolution cannot be schema-qualified and still requires bm25_catalog on
the path. Tenant tables are always accessed via fq_table(), so this does
not affect schema isolation.
2026-06-05 12:46:54 +02:00
Nicolò Boschi a3d3d42b39 feat(llm): honor HINDSIGHT_API_LLM_STRICT_SCHEMA on all json_schema-capable providers (#2003)
Structured-output calls (retain fact extraction, consolidation observation
merge) use a soft "schema-in-prompt + json_object" path by default: the schema
is appended to the prompt and the model must voluntarily emit valid JSON. Strong
hosted models comply, but weaker self-hosted instruction-followers (small
Qwen/Llama/Mistral GGUF via llama.cpp/vLLM) return prose preambles, markdown
fenced blocks, or invalid JSON that fails to parse — retain/consolidation then
retry forever and wedge.

#1986 added a HINDSIGHT_API_LLM_STRICT_SCHEMA flag but wired it into only the
OpenAI-compatible provider, leaving LiteLLM and the batch retain path ignoring
it. Resolve the flag once in LLMProvider.call (OR-ed with the per-call
strict_schema arg) and pass it down instead, so every json_schema-capable
provider honours it through its existing strict_schema handling:

- OpenAI-compatible (+ llama.cpp delegate, Fireworks subclass) and LiteLLM:
  json_schema strict instead of soft json_object.
- Gemini already grammar-enforces its native response_schema (no-op).
- Batch retain path builds its request body directly (bypasses .call()), so it
  reads the flag itself and sets json_schema strict.

Providers without a strict mode (Anthropic, Claude Code, Codex) ignore the flag
and keep the soft path — unchanged.

Default false, so no behavior change for existing deployments. Corrects the
stale "OpenAI only" docstrings, documents the env var in configuration.md, and
adds tests/test_llm_strict_schema.py (config parsing, wrapper resolution,
openai/litellm/batch mappings).
2026-06-05 12:30:48 +02:00
Nicolò Boschi 01296d8d52 feat(llm): apply HINDSIGHT_API_LLM_EXTRA_BODY across all API providers (#2006)
extra_body was only threaded into the OpenAI-compatible (and Fireworks)
providers. Extend it to Anthropic, Gemini/VertexAI and LiteLLM (incl. the
Bedrock alias and the LiteLLM Router) so the same env-configured knob
(temperature, top_p, max_tokens, ...) tunes every provider with no code
changes — closing the gap reported in #1227.

Each provider merges the params in its own native space:
- Anthropic: Anthropic SDK extra_body kwarg (call + call_with_tools)
- Gemini/VertexAI: seeded into GenerateContentConfig (explicit per-call
  values win); Gemini nests generation params in the body
- LiteLLM/Bedrock/Router: top-level acompletion kwargs via setdefault so
  LiteLLM normalizes/drops them per-provider

Stays server-level (env) only — not per-bank configurable.

The docs-skill regen also syncs a small pre-existing drift (Fireworks AI
in the provider/integration lists).

Refs #1227
2026-06-05 12:30:26 +02:00
Nicolò Boschi e77931fa22 docs(performance): expand local-LLM concurrency guidance into a Local & Small Environments tuning section (#2002)
* docs(performance): add Tuning for Local & Small Environments section

Supersedes #1721. Keeps the local-LLM concurrency guidance from that PR
(HINDSIGHT_API_LLM_MAX_CONCURRENT, saturation symptom + diagnostics) and
expands it into a dedicated section covering the other knobs that matter
on laptops, single-GPU boxes, and local LLM servers:

- per-operation concurrency caps to reserve reflect headroom
- timeouts/retries for slow local generation
- smaller per-operation models + low reasoning effort + LLM=none
- built-in llama.cpp tuning (gpu layers, context size, threads, grammar)
- CPU reranker knobs (fp16, bucket batching, max concurrent, flashrank)
- CPU embeddings (force_cpu)

* docs(performance): drop saturation symptom + diagnostics block

* docs(performance): drop LLM_PROVIDER=none chunk-mode note

* docs(performance): add reranker candidate-set + consolidation batch-size levers; drop CPU embeddings note
2026-06-05 11:55:50 +02:00
23710f4a8f fix(oracle): make recall and mental-model history work on the Oracle backend (#1980)
* fix(oracle): make recall and mental-model history work on the Oracle backend

Two code paths emitted PostgreSQL-specific SQL that has no Oracle equivalent
and is not handled by the PG→Oracle query rewriter, so they raised hard
errors on the Oracle 23ai backend:

1. Recall — `retrieve_temporal_combined` expands a batch of seed ids for
   multi-hop temporal-link spreading with `FROM unnest($2::uuid[]) AS
   src(from_unit_id)`. Oracle has no `unnest`, so recall raised
   `ORA-03048` whenever the matched memories had temporal/causal links
   (the common case). Fix: guard the spreading loop on the connection's
   `backend_type`; on backends without `unnest` we skip only the multi-hop
   spread. The temporal entry points are still returned, and the
   semantic / keyword / graph retrievers are unaffected.

2. Mental-model history — `update_mental_model` trims the history array in
   SQL with `jsonb_agg(... ORDER BY ...)` over
   `jsonb_array_elements(...) WITH ORDINALITY`, which raised `ORA-00907`
   and made mental-model creation fail (the create path triggers a refresh
   that updates content). Fix: on Oracle, compute the trimmed history in
   Python (we already fetch the current array) and bind it as a single JSON
   value. The PostgreSQL SQL path is unchanged.

Both are instances of the dialect-asymmetry trap called out in CLAUDE.md.

Test plan:
- Oracle 23ai e2e smoke + HTTP integration: mental-model create/CRUD and
  full-lifecycle (previously failing with ORA-00907) now pass.
- Full Oracle integration suite shows zero ORA-03048 occurrences.
- PostgreSQL mental-model history unit tests (including max-entries
  trimming) still pass — the PG path is byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix(oracle): return CLOB columns from RETURNING without a 4000-byte cap

The Oracle backend's RETURNING handler bound every non-numeric, non-timestamp
output column as DB_TYPE_VARCHAR. VARCHAR out-binds cap at 4000 bytes, so any
CLOB-backed column returned via a RETURNING clause raised
`ORA-22835: buffer too small for CLOB to CHAR conversion` once its value
exceeded 4000 bytes. This surfaced as mental-model creation failing on Oracle:
the post-create refresh UPDATEs `content` (a CLOB) with `RETURNING content`,
and a sufficiently long synthesized snapshot (>4000 bytes) aborted the update.

Fix: bind known CLOB columns (the JSON-as-CLOB set plus the large-text columns
content/text/context/structured_content/text_signals/search_vector) as
DB_TYPE_CLOB in the RETURNING var setup, and read the LOB handle back to a
string in _read_returning_values (the async pool yields AsyncLOB, whose read()
is awaited). Non-CLOB columns are unchanged.

Verified against Oracle 23ai:
- A 4277-byte CLOB now round-trips through UPDATE ... RETURNING (previously
  ORA-22835); other columns (RAW(16) ids, etc.) still convert correctly.
- Mental-model create/refresh with large content succeeds.
- RETURNING-heavy Oracle integration tests (retain, tags, document/memory CRUD,
  http retain/recall, full lifecycle) pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix(oracle): make the temporal entry-point query Oracle-compatible (no unnest)

The temporal-recall entry-point selection was rewritten on main (#1983) to gate
candidates by embedding similarity within the window. That new query expanded the
fact_types with `FROM unnest($3::text[]) AS ft CROSS JOIN LATERAL (...)`, which has
no Oracle equivalent — so after merging main, Oracle recall would again fail with
ORA-03048 on any temporal query, in the entry-point query this time (the spreading
guard added here only covers the multi-hop spread).

Rebuild the entry-point query as a UNION ALL of one similarity-ranked,
window-filtered arm per fact_type with the fact_type inlined as a literal — the
same shape retrieve_semantic_bm25_combined already uses and which the Oracle
backend runs. The `<=>` operator and `LIMIT` are translated to VECTOR_DISTANCE and
FETCH FIRST on execute; only `unnest` was untranslatable, and it's now gone.

Behavior on PostgreSQL is unchanged (each arm still hits the per-(bank, fact_type)
vector index; selection + coverage logic is identical) — verified by the existing
temporal selection tests and the recall_perf temporal benchmark (temporal arm
~0.002s on the 680k dense bank). Oracle output verified through the real
_rewrite_pg_to_oracle translator: no unnest, valid VECTOR_DISTANCE + FETCH FIRST.

---------

Co-authored-by: DK09876 <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-05 11:29:05 +02:00
cinos b5a324b77b feat(embeddings): add ONNX local provider (#1970)
* feat(embeddings): add ONNX local provider

* fix(embeddings): download ONNX external data sidecars

* fix(embeddings): address ONNX provider review feedback
2026-06-05 11:24:34 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> adbad877d5 chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#1938)
Bumps the npm_and_yarn group with 1 update in the /hindsight-integrations/flowise directory: [uuid](https://github.com/uuidjs/uuid).


Removes `uuid`

Updates `langsmith` from 0.3.87 to 0.7.3
- [Release notes](https://github.com/langchain-ai/langsmith-sdk/releases)
- [Commits](https://github.com/langchain-ai/langsmith-sdk/commits/v0.7.3)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version:
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: langsmith
  dependency-version: 0.7.3
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 11:24:07 +02:00
Evo d3eff9fba2 docs(admin-cli): document full backup table coverage from #1903 (#1929)
#1903 expanded BACKUP_TABLES to all 15 tables (the 7 previously-missing ones that could be silently dropped on restore), but the "backup includes" list still reflected the old ~8-table coverage. Update it to match: mental models, directives, webhooks, file storage, plus internal operational tables for a faithful full-database snapshot. Oracle-only observation_sources stays excluded (PostgreSQL-only backup). Regenerated skills/hindsight-docs mirror.
2026-06-05 11:23:39 +02:00
Evo ddef3d8c6b docs(cli): replace removed opinion fact-type with observation in recall example (#1917) 2026-06-05 11:23:12 +02:00
Evo ab61330698 docs(models): register fireworks so the Models grid + default-models table list it (#1860) (#1911)
* docs(models): register fireworks in llmProviders.json (#1860)

#1860 added fireworks to PROVIDER_DEFAULT_MODELS (config.py:535) but not to the
providers registry that renders the Models page grid + default-models table. The
registry docstring mandates it stay aligned with PROVIDER_DEFAULT_MODELS.

* docs(models): regenerate skills mirror for fireworks provider

Mirror of the generated <LLMProvidersGrid/> + <LLMProvidersTable/> output.
2026-06-05 11:22:16 +02:00
Stefan Weber 505a013812 Added OutSystems community integration (#1873)
Added integration entry and vendor icon
2026-06-05 11:21:13 +02:00
Derek Bouius a3797e2014 docs: update Gemini model recommendations to 3.x series (#1787)
Replace deprecated Gemini models with their 3.x successors:
- gemini-3-pro-preview → gemini-3.1-pro-preview (shut down March 2026)
- gemini-2.5-flash → gemini-3.5-flash
- gemini-2.5-flash-lite → gemini-3.1-flash-lite

Also update default models in config.py for gemini and vertexai providers.
2026-06-05 11:20:36 +02:00
Derek Bouius 1615456384 chore: update gemini embedding model from preview to GA (#1780)
Replace gemini-embedding-2-preview with gemini-embedding-2 in LiteLLM
SDK embedding tests now that the GA model is available.
2026-06-05 11:20:12 +02:00
Manfred + TARS 06c88e0435 fix: validate embedding dimensions before pgvector writes (#1670)
* fix: validate retain embedding dimensions

* test: cover consolidation embedding dimension validation

* test: align consolidation embedding fake with document encoder

* style: format embedding validation error message
2026-06-05 11:17:38 +02:00
Nicolò Boschi 8aa31edd4c feat(consolidation): enable observation dedup by default (0.97), skip on Oracle (#2000)
The create+update semantic dedup added in #1977 shipped opt-in (threshold 1.0).
Enable it by default at 0.97 so observations are deduplicated out of the box.

The merge path uses Postgres-only SQL, so consolidation skips dedup entirely on
Oracle (via _dedup_active) — it behaves exactly as before there, regardless of
the configured threshold. This is what lets the default flip without breaking
Oracle deployments.

Also fix MockLLM to return a valid keep-decision for the consolidation_dedup
scope, so mock-LLM consolidation tests (which now exercise the enabled-by-default
path) don't crash on the structured response and never spuriously merge.
2026-06-05 11:10:45 +02:00
Nicolò Boschi 4c33a4e55b fix(recall): bound temporal entry-point scan to top-50-per-fact_type (alternative to #1958) (#1983)
* fix(recall): select temporal entry points by similarity with window coverage

retrieve_temporal_combined Phase 1 ranked the *entire* date-window match set by
COALESCE(occurred_start, mentioned_at, occurred_end) and kept the 50 most recent.
Two problems, one perf and one functional:

- Perf: on banks with dense/near-uniform date metadata (e.g. a retain pipeline
  that stamps a large batch with one date) any recall window intersects
  (near-)all rows, so Phase 1 degraded to a full sequential scan + disk-spilling
  sort. EXPLAIN on a 680k-row bank: Seq Scan 680k + Sort 680k to keep 50
  ("Rows Removed by Filter: 679,950"), ~672ms Phase 1 alone (30s+ in prod).
- Functional: ranking by recency biases results toward the END of the window,
  and when dates are degenerate the "50 most recent" is a near-random sample
  that can drop the single most relevant in-window memory before similarity is
  ever considered.

Switch the entry-point gate to embedding similarity within the window
(ORDER BY embedding <=> query, per fact_type, LIMIT pool), then narrow the pool
to N per fact_type with coverage-first round-robin across time-buckets so the
entry points span the window's range instead of clustering. Degenerate dates
collapse to plain similarity order.

The planner serves the similarity-ordered window query from the existing
per-(bank, fact_type) HNSW index when the window is broad (the dense case) and
from the existing partial date indexes + an exact sort when it is narrow — so no
new index is needed. (An earlier revision of this PR added a recency expression
index; Option A makes it unnecessary, so it's removed.)

Measured on a 680k-row dense-date bank (recall_perf): temporal arm
1.174s -> 0.009s; the arm is now both fast and returns the most relevant
in-window memories, spread across the window.

This is the alternative to #1958, which skipped the temporal arm entirely above a
planner row estimate (losing temporal recall on large banks).

- tests (no LLM): coverage round-robin + degenerate-date fallback (pure
  selector); similarity-over-recency selection and window filtering (DB-backed)
- recall_perf: `generate --event-date` (dense zone) + `benchmark
  --temporal-date` (forces the temporal arm) to reproduce and track this

* docs(retrieval): explain temporal selection (relevance-gated + window coverage)
2026-06-05 10:57:38 +02:00
Evo 72985b6153 docs(admin-cli): document decommission-worker --yes/-y confirmation-skip flag (#1957) 2026-06-05 10:53:32 +02:00
Evoandr266-tech 8872b9d9ef docs(configuration): document HINDSIGHT_API_WORKER_IMPORT_DOCUMENTS_MAX_SLOTS worker slot reservation (#1978)
Co-authored-by: r266-tech <[email protected]>
2026-06-05 10:51:58 +02:00
formatme 56ed38c8c5 fix(mental-models): create bank before insert (#1994) 2026-06-05 10:49:49 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e0704a445e chore(deps): bump the uv group across 18 directories with 2 updates (#1982)
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: direct:production
  dependency-group: uv
- dependency-name: starlette
  dependency-version: 1.0.1
  dependency-type: indirect
  dependency-group: uv
- dependency-name: starlette
  dependency-version: 1.0.1
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: starlette
  dependency-version: 1.0.1
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: direct:production
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: starlette
  dependency-version: 1.0.1
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: starlette
  dependency-version: 1.0.1
  dependency-type: indirect
  dependency-group: uv
- dependency-name: aiohttp
  dependency-version: 3.14.0
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-05 10:47:07 +02:00
Evo 40871e231e docs(api/bank-templates): fix entity_labels manifest example — label-group objects, not string[] (#1984)
The Manifest Schema example documented entity_labels as a bare string array
(`["PERSON", "ORGANIZATION"]`), but BankTemplateConfig.entity_labels is
`list[dict[str, Any]]` and each entry is parsed via LabelGroup (which requires
a `key`). A bare string fails import validation, so the documented example is
not usable. Replace it with a minimal valid label group and point the field
table at the authoritative shape already documented in memory-banks.mdx.
2026-06-05 10:46:10 +02:00
Evo 56b4271d9f docs(models): vertexai default is retired gemini-2.0-flash-001 -> sync to gemini-2.5-flash-lite (#2001)
The Provider Default Models table advertised vertexai's default as
gemini-2.0-flash-001, which #1972 confirms is retired on Vertex AI
(404 NOT_FOUND). The live config default is google/gemini-2.5-flash-lite
(config.py:562 PROVIDER_DEFAULT_MODELS); the google/ prefix is stripped
for display. Regenerated the skills-docs mirror via generate-docs-skill.sh.
2026-06-05 10:44:48 +02:00
Evo 1226fd96ad docs(configuration): document HINDSIGHT_API_LLM_PROMPT_CACHE_ENABLED in LLM Provider table (#1990)
#1936 added the on-by-default HINDSIGHT_API_LLM_PROMPT_CACHE_ENABLED env var
but documented it only in models.mdx prose. Add the missing row to the
canonical LLM Provider table so operators can discover the cached-input
billing toggle from the env-var reference. Regenerated the skills mirror.
2026-06-05 10:41:42 +02:00
Evo 087a729d57 docs(retrieval): correct equal-weight claim after RECALL_STRATEGY_BOOSTS (#1974) (#1991)
#1974 added HINDSIGHT_API_RECALL_STRATEGY_BOOSTS (named low/medium/high
per-source boosts), making retrieval.md's absolute claim 'There are no
per-strategy weight multipliers' factually wrong. Scope the equal-weight
statement to RRF fusion itself, point readers to the boost knob, and note
at the pre-filter cap stage that boosted sources are more likely to survive.
Regenerated the skills mirror.
2026-06-05 10:41:24 +02:00
FelixandClaude Opus 4.8 c5a61db2b8 fix(integrations): raise _check_health default timeout 2s→10s to stop busy-daemon kill loop (#1992)
Under load an alive-but-busy daemon (mid 30–60s LLM fact-extraction) can fail
to answer GET /health within the 2s default. That false negative makes
get_api_url() fall through to _ensure_daemon_running() →
`hindsight-embed daemon start`, whose _clear_port() then SIGTERMs the live
daemon — producing a daemon restart/kill loop under sustained traffic.

Raise the default to 10s, matching the recall hook's own budget (referenced in
get_api_url's docstring), so a busy daemon has time to respond before it is
declared dead. Callers passing an explicit timeout are unaffected. Applied to
both the claude-code and codex integrations, which share the helper verbatim.

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-06-05 10:40:52 +02:00
Evo 86ec97183b docs(configuration): document HINDSIGHT_API_BANK_STATS_CACHE_TTL_SECONDS + _MAX_ENTRIES (#1993) 2026-06-05 10:40:22 +02:00
Nicolò Boschi 221acc8f66 release(claude-code): v0.7.1 2026-06-05 10:39:43 +02:00
Nicolò Boschi f4a3329cea feat(api): enable LLM request tracing by default with 1-day retention (#1996)
Flip DEFAULT_LLM_TRACE_ENABLED to True and DEFAULT_LLM_TRACE_RETENTION_DAYS
to 1 so LLM request traces are captured out of the box and swept after a
day. The retention sweep already enforces >0 day windows; existing tracing
tests toggle the recorder explicitly and are unaffected.
2026-06-05 10:39:33 +02:00
Nicolò Boschi 655435ea49 fix(claude-code): default enableKnowledgeTools to true; keep MCP server alive when disabled (#1999)
On a fresh plugin install the MCP server is registered unconditionally in
.mcp.json but exited immediately when enableKnowledgeTools was false (the
shipped default), so Claude Code reported a -32000 reconnect error on every
prompt.

- Default enableKnowledgeTools to true (settings.json + config DEFAULTS).
- When disabled, run an empty MCP server instead of exiting, so the
  registered process stays alive and no reconnect error is surfaced.

Fixes #1995
2026-06-05 10:38:46 +02:00
Nicolò Boschi 0db70bb88a fix(clients): expose reflect tool_calls/llm_calls trace in python + typescript wrappers (#1997)
* fix(python-client): expose reflect tool_calls/llm_calls trace in wrapper

The maintained high-level wrapper only exposed include_facts on
reflect()/areflect(), so there was no way to request the reflect trace
(trace.tool_calls / trace.llm_calls) without dropping down to the
generated API. The wire API and generated models already support it.

Add include_tool_calls and include_tool_call_output params to both
reflect() and areflect(), mapping them to ReflectIncludeOptions.tool_calls.
Add unit tests pinning the wrapper -> ReflectRequest.include mapping.

* fix(ts-client): expose reflect tool_calls/llm_calls trace (+facts) in wrapper

The TS wrapper's reflect() never sent an 'include' object, so the reflect
trace (trace.tool_calls / trace.llm_calls) and based_on facts were
unreachable from the convenience layer. The wire API and generated types
already support both.

Add includeFacts, includeToolCalls, and includeToolCallOutput options to
reflect(), mapping them onto ReflectRequest.include. Add mock-based unit
tests pinning the option -> include mapping.
2026-06-05 10:38:36 +02:00
Nicolò Boschi 61f9bc8c77 feat(consolidation): semantic dedup of near-duplicate observations (create + update) (#1977)
Weak consolidation models (e.g. gemini-2.5-flash-lite) emit near-duplicate
observations even when the twin is in context, and an UPDATE that rewrites +
re-embeds an observation can drift it into a near-twin of a different existing
observation. When consolidation_dedup_threshold < 1.0, an observation that is
>= the threshold cosine to an existing one is reconciled by a focused 1-by-1 LLM
"merge or keep" call (anchored on the observation text, not the source fact, so
it is the correct obs<->obs comparison):

- CREATE path: on "merge", fold the new source facts + synthesized text into the
  existing twin and skip the insert.
- UPDATE path: after the rewrite+re-embed, probe the new vector (excluding the
  row itself); on "merge", fold the updated observation's sources into the twin
  and delete the now-redundant updated row.

Default 1.0 disables it (no behaviour change). Postgres only. On the English
hermes obs benchmark with flash-lite at 1/4 scale, residual >=0.97 near-dups
drop from ~7% to 0-1%.
2026-06-05 10:19:11 +02:00
Ben d826d648d8 release(llamaindex): v0.1.5 2026-06-04 15:02:10 -04:00
DK09876andDK09876 ed34756cdc fix(llamaindex): default to Cloud + replace dead manual test with gated E2E + requires_real_llm bucketing (#1867)
* fix(llamaindex): default to Cloud without configure(); replace dead manual test with gated E2E; bucket

- resolve_client() now falls back to DEFAULT_HINDSIGHT_API_URL and the
  HINDSIGHT_API_KEY env var when configure() was never called (was raising).
  Updated the raise-test to assert the cloud-default + env-key behavior.
- Replace the [email protected]'d tests/test_manual.py (dead code —
  the class-level skip made it never run anywhere) with a real, gated
  tests/test_e2e.py covering the create_hindsight_tools roundtrip
  (retain/recall/reflect via tool.call()) AND the HindsightMemory.aget/put
  roundtrip against a live Hindsight server.
- Marked requires_real_llm; register the marker in pyproject; add the missing
  asyncio_mode = "auto"; the test-llamaindex-integration CI job now runs the
  deterministic bucket (-m "not requires_real_llm").

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(llamaindex): give HindsightMemory.from_defaults a real cloud-default ctor

Audit finding (2026-06-02): HindsightMemory's create paths are asymmetric
with what create_hindsight_tools offers. The tools factory uses
resolve_client() so callers get the standard cloud-default + env-var
fallback for free; the memory adapter required either an explicit client
(from_client) or an explicit URL (from_url) and its from_defaults() raised
NotImplementedError. Callers wanting the same "no-config → Cloud" path
had to wire it themselves.

Fix: from_defaults(bank_id, ...) now calls resolve_client() exactly the
way the tools factory does. Falls back to DEFAULT_HINDSIGHT_API_URL when
no URL is supplied; reads HINDSIGHT_API_KEY from the environment if no
api_key is supplied; explicit `client=` still wins.

Tests pinning the new behaviour:
- from_defaults with nothing supplied → Hindsight constructed with
  DEFAULT_HINDSIGHT_API_URL.
- from_defaults with api_key → constructed with the configured key.
- from_defaults with explicit client → no new Hindsight constructed.

Replaces the previous test_from_defaults_raises (which pinned the
NotImplementedError that we're removing).

Verification:
- Deterministic bucket: 86 pass / 4 deselected (84 prior + 2 new
  cloud-default tests; one prior raises-test rewritten).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(llamaindex): reword 'Hindsight Cloud' in HindsightMemory.from_defaults docstring

V2 audit (2026-06-02) caught one 'Hindsight Cloud' literal introduced by
the cloud-default ctor fix (commit 92926e2c) at memory.py:126. Reworded
to drop the product name parenthetical — DEFAULT_HINDSIGHT_API_URL is
self-explanatory.

Goal-4 (OSS-clean) compliance restored. Behaviour unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(llamaindex): fall back to last user msg when aget() input is None

HindsightMemory.aget() only triggered automatic recall when called with
input=<query>. Workflow-based agents in current LlamaIndex
(llama_index.core.agent.workflow.ReActAgent, FunctionAgent, etc.) call
memory.aget() WITHOUT input= on their main path. Result: Pattern 1
(HindsightMemory as a drop-in BaseMemory) silently stopped surfacing
recalled memories — retain still fired, but the recalled facts were never
injected into the agent's context. Cross-session memory looked broken even
though the bank had the right content.

Reproduced in the canonical cookbook (notebooks/08-llamaindex-react-agent
cell 7 returned "No tengo acceso a información..." after cell 5 stored
Alice's facts) and in a real-app smoke test.

Fix: when aget()/get() is called without input, fall back to the most
recent USER ChatMessage in local history as the recall query. That message
is already populated by the workflow agent's aput(user_msg) call before
aget(). If there's no user message in history, skip recall — no
semantically meaningful query to look up.

Verified end-to-end:
  S1 (write): agent.run("I'm Alice, data engineer at Acme, write Python,
              use Neovim", memory=mem1)
  10s wait
  S2 (fresh memory + agent.run("What's my name and editor?", memory=mem2))
    → "Your name is Alice, and you use Neovim as your editor."

Regression tests:
- test_get_without_input_falls_back_to_last_user_message — asserts recall
  fires with the last user msg as query
- test_get_without_input_and_empty_history_skips_recall — boundary case
- test_get_without_input_and_no_user_msg_skips_recall — only assistant
  history, no recall

The existing test_get_without_input_returns_history asserted recall was
NOT called when input was None; that assertion was load-bearing on the
old (broken-for-workflow-agents) behavior and is replaced by the three
tests above. 38/38 tests in test_memory.py pass.


---------

Co-authored-by: DK09876 <[email protected]>
2026-06-04 15:01:09 -04:00
Ben 28044b1782 blog(google-adk): update cover image (#1985)
* blog(google-adk): update cover image
2026-06-04 14:37:14 -04:00
Ben bfdcb366d7 blog: Long-Term Memory for Google ADK Agents with Hindsight (#1979)
* blog: Long-Term Memory for Google ADK Agents with Hindsight

Introduces the hindsight-google-adk integration. Covers the drop-in
BaseMemoryService path (Runner takes care of add_session_to_memory /
search_memory automatically), the alternative FunctionTool path for
mid-turn agent-driven retain/recall/reflect, bank-scoping patterns
({app_name}::{user_id} default with overrides), and production patterns
(per-environment tagging, bootstrapped banks with a mission, self-hosted
Hindsight, recall budget).
2026-06-04 14:19:52 -04:00
Chris BartholomewandNicolò Boschi 7683f29004 refactor(engine): cheaper bank stats — drop unused join, add freshness helper, result cache (#1859)
* feat(engine): TTL + coalescing cache for get_bank_stats

The bank stats query joins memory_links to memory_units and aggregates by
(fact_type, link_type). On large banks the link side can run into millions
of rows, making each call a multi-second parallel scan. The result is
inherently approximate — it backs a UI widget and a freshness hint in
reflect — so a short result cache is safe.

Adds BankStatsCache: per-process TTL cache keyed on (schema, bank_id) with
LRU eviction and concurrent-miss coalescing, so N callers that arrive on
the same cold key produce one DB roundtrip instead of N. Wired into
MemoryEngine.get_bank_stats after auth and validation; the DB body moves
to _compute_bank_stats unchanged.

Tunable via HINDSIGHT_API_BANK_STATS_CACHE_TTL_SECONDS (default 60s,
set to 0 to disable) and HINDSIGHT_API_BANK_STATS_CACHE_MAX_ENTRIES
(default 1024).

* refactor(engine): drop unused memory_links⇒memory_units join in bank stats

get_bank_stats used to compute a (fact_type, link_type) matrix joining
memory_links to memory_units to pick up the originating unit's fact_type.
On large banks that join can take seconds — and an audit of every caller
(UIs, MCP tool, SDK clients, integrations) shows that the matrix
(`link_breakdown`) and its fact-type rollup (`link_counts_by_fact_type`)
are declared in response types but never actually read.

This refactor:

* Replaces the JOIN with a single-table GROUP BY link_type on
  memory_links plus a small per-entity rollup over unit_entities. Both
  are cheap with the existing indexes and stay cheap even at multi-
  million-row scale.
* Keeps `links_breakdown` and `links_by_fact_type` in the response shape
  (returning empty values) so SDKs and openapi-generated clients do not
  break.
* Adds `MemoryEngine.get_bank_freshness(bank_id)` — a one-row aggregate
  over memory_units that returns just last_consolidated_at /
  pending_consolidation / failed_consolidation. Switches `reflect()` to
  call it; reflect used to call get_bank_stats and discard everything
  except those two scalars (and the previous hasattr-on-dict access
  pattern meant it was reading None back anyway).
* Adds three tests: stats response shape, freshness method correctness,
  and a regression test that reflect() never invokes the heavy stats
  loader.

Together with the result cache added in the previous commit, the
expensive per-bank join is no longer on any hot path.

* docs(engine): correct bank stats comments — hindsight-cli still reads the deprecated fields

The prior comments asserted "no consumer reads" link_counts_by_fact_type /
link_breakdown. That was wrong: hindsight-cli's `bank stats` renderer
iterates both. The data still degrades gracefully there (one section
prints empty, the other is skipped by an is_empty() guard), but the
deprecation note should reflect reality so the next reader doesn't
assume the CLI was audited and rip the fields out without updating it.

* fix(engine): invalidate bank stats cache on delete_bank / clear_memories

The TTL cache was serving pre-deletion counts for up to 60s after
delete_bank() (which also backs the DELETE /memories "clear" path),
breaking the contract that callers see fresh data immediately after a
destructive op. Two http integration tests were failing on shard 2/3
because the second stats read returned the cached pre-delete value.

Wire BankStatsCache.invalidate() into delete_bank after the deletion
commits. Other write paths (retain, consolidate) only loosen counts and
remain TTL-bounded — staleness there is acceptable polling behavior.

* docs(engine): clarify get_bank_freshness keeps failed_consolidation for contract

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-04 18:14:27 +02:00
Ben b383c6edd9 release(autogen): v0.1.3 2026-06-04 11:25:12 -04:00
DK09876andDK09876 0eb40a7c1b fix(autogen): default to Cloud + gated E2E + bucketing + add missing CI job (#1868)
- resolve_client() now falls back to DEFAULT_HINDSIGHT_API_URL and the
  HINDSIGHT_API_KEY env var when configure() was never called (was raising).
  Updated the raise-test to assert the cloud-default + env-key behavior.
- Fix two pre-existing broken tests (test_falls_back_to_global_config /
  test_explicit_url_overrides_config): mock_cls.assert_called_once_with was
  missing user_agent — switched to loose call_args.kwargs checks.
- Add a gated tests/test_e2e.py (retain/recall/reflect via tool.run_json) and
  mark requires_real_llm; register the marker.
- ADD the missing test-autogen-integration CI job (the autogen/ package had
  ZERO CI coverage — only ag2/ had a job). 4 places:
  * detect-changes output integrations-autogen
  * path filter hindsight-integrations/autogen/**
  * test-autogen-integration job (runs -m "not requires_real_llm")
  * test-autogen-integration entry in the aggregate gate

Co-authored-by: DK09876 <[email protected]>
2026-06-04 11:12:26 -04:00
Chris BartholomewandNicolò Boschi d7a3aa5269 feat(llm): provider prompt-prefix caching — retain + consolidation + reflect (bank-agnostic, default-on) (#1936)
* feat(gemini): add context-cache foundation (GeminiCacheManager + opt-in call() arg)

Wraps the google-genai SDK's CachedContent API so callers can reuse a
stable (system_instruction + response_schema) prefix across many
requests. Cached input tokens are billed at a fraction of the standard
input rate, which makes workloads with a fixed-prefix / small-user-message
shape — fact extraction, structured tagging, classification — far
cheaper to run.

This PR is foundation-only: no caller is wired up yet. Default
behaviour for every existing path is unchanged because
`cached_content_name` defaults to `None` and the cache manager is
never instantiated until a follow-up wires it in.

What's here
-----------
- `gemini_cache.GeminiCacheManager`: per-process map of prefix
  fingerprint → CachedContent resource name. Thread-safe via a single
  asyncio.Lock. Refreshes proactively at TTL minus a safety margin.
  Stable fingerprint normalisation strips auto-generated Pydantic
  schema titles so dynamically-built schema classes with identical
  shape hash to the same key (relevant for callers that rebuild the
  schema class on every request).
- `gemini_llm.GeminiLLM.call(cached_content_name=...)`: new optional
  arg. When set, the SDK config drops `system_instruction` and
  `response_schema` (those live in the cache) and instead passes
  `cached_content` to GenerateContentConfig. When unset, behaviour is
  byte-identical to before.
- `tests/test_gemini_cache.py`: 10 unit tests covering fingerprint
  stability, dict/list/Pydantic schema cases, get_or_create
  caching/recreate, "minimum token count" soft-fallback, transient
  SDK error soft-fallback, failed-create-doesn't-poison-cache, and
  the TTL refresh boundary.

Failure handling
----------------
- Gemini rejects creates whose prefix is below the model's minimum
  cacheable size with a "minimum"-style error message. The manager
  catches this, logs at DEBUG, and returns None so the caller
  transparently falls back to a non-cached call.
- Any other SDK error is logged at ERROR and also returns None — a
  bad create never crashes a request. Callers are required to treat
  None as "cache unavailable, use the normal path".

Not in this PR
--------------
- Wiring this into the fact-extraction pipeline (or any other caller)
- A metric for cached-token volume
Both will come in a focused follow-up so the foundation can land and
be reviewed independently.

* feat(gemini): wire retain fact-extraction to context cache; surface cached + thoughts tokens

Follow-on to the foundation commit on this branch — without this, the
cache manager is unreachable and the metric ignores half the cost
surface. This commit makes the change actually do something when the
flag is flipped on.

What lands
----------
1. Retain fact-extraction (engine/retain/fact_extraction.py) opts into
   the cache. The system prompt and response schema are fingerprinted
   and reused across calls; the user message is the only variable
   part on the wire. A cache lookup failure or "prefix too small"
   response from Gemini transparently falls back to the existing
   uncached path — caching is a soft optimisation, never a blocker.

2. New top-level flag HINDSIGHT_API_LLM_GEMINI_PROMPT_CACHE_ENABLED
   (also exposed as ``llm_gemini_prompt_cache_enabled`` on
   HindsightConfig). Defaults to False so upgrade-and-do-nothing is a
   no-op. Flipping to True opts every Gemini caller (currently only
   retain) into context caching.

3. Two new metrics:
   - hindsight.llm.tokens.cached_input — subset of input tokens billed
     at the cached rate. Lets dashboards split cache-hit vs cache-miss
     volume independently of total throughput.
   - hindsight.llm.tokens.thoughts — reasoning tokens emitted by
     Gemini 2.5+. Billed at the output rate by the provider but
     invisible to candidates_token_count, so absent from output-token
     dashboards today. Surfacing this is required for honest cost
     attribution.

4. Provider plumbing: GeminiLLM gains a ``gemini_prompt_cache_enabled``
   kwarg and a ``get_or_create_cached_prefix(...)`` accessor that lazy-
   builds a GeminiCacheManager on first opt-in. LLMProvider /
   create_llm_provider / ConfiguredLLMProvider pass the flag through
   the standard plumbing alongside the existing safety_settings.

Verification
------------
- ``uv run ruff check`` — clean
- ``uv run pytest tests/test_gemini_cache.py`` — 12 tests including
  two new integration tests that pin (a) flag-off → cache manager
  never built, and (b) flag-on → manager lazy-built, second lookup
  served from in-memory cache, no extra SDK call.
- ``uv run pytest tests/test_gemini_safety_settings.py`` — 13 tests
  still green (no signature drift; the NoOp metrics collector was
  updated alongside the real one).

Rollout
-------
- Land this commit. With the flag default-off, behaviour is identical
  to today: cache code paths exist but are never reached.
- Flip the flag per-env. The metric goes non-zero on cached_input
  within a few calls.
- Watch hindsight.llm.tokens.cached_input vs hindsight.llm.tokens.input
  to confirm cache-hit rate.

What's deliberately NOT in this PR
----------------------------------
- Extending caching to other Gemini callers (reflect tool-call,
  consolidation). Same mechanism applies — copy two lines from the
  retain path. Leave for a follow-up so this lands in one focused PR.
- Cross-pod cache sharing. Each pod warms its own cache. The cost of
  one extra full-price call per pod per fingerprint per TTL window is
  negligible relative to steady-state savings.

* feat(gemini): extend context caching to the tool-calling reflect loop

Adds caching support to the agentic tool-loop path. The reflect agent's
``system_prompt + tools`` is stable for the duration of a single reflect
(and across reflects against the same bank), so caching them once and
reusing the cache name across every iteration of the loop collapses the
dominant input cost — the prefix repeated on every turn.

Mechanism
---------
1. ``GeminiCacheManager.fingerprint(...)`` now accepts ``tools`` and
   includes the OpenAI-style tool list in the hash. A loop that swaps a
   tool gets a fresh cache automatically; a loop that doesn't, hits the
   cache deterministically. The tool list is serialised with sort_keys
   so upstream dict-reordering doesn't cause phantom cache misses.

2. ``GeminiCacheManager.get_or_create(...)`` accepts ``tools`` and
   converts the OpenAI-style entries into Gemini ``Tool`` /
   ``FunctionDeclaration`` shapes inside ``CreateCachedContentConfig``.
   The cached prefix now holds system_instruction + tools, so the
   subsequent ``call_with_tools(cached_content_name=...)`` invocation
   skips resending both.

3. ``GeminiLLM.call_with_tools(...)`` gains ``cached_content_name``.
   When set, ``system_instruction`` and ``tools`` are dropped from the
   per-request config (the SDK rejects re-sending them alongside
   ``cached_content``); ``tool_config`` (mode / allowed_function_names)
   stays per-request as it must.

4. ``GeminiLLM.get_or_create_cached_prefix(...)`` accepts ``tools``
   and forwards them to the cache manager.

5. ``reflect/agent.py:run_reflect_agent`` looks up (or creates) the
   cached prefix ONCE per reflect — right after the ``system_prompt``
   and ``tools`` are built — and reuses the returned cache name across
   every iteration of the agentic loop. The lookup is wrapped in a
   try/except so a cache-side failure can never block a reflect.

6. ``call_with_tools`` now extracts ``cached_content_token_count``
   and ``thoughts_token_count`` from ``usage_metadata`` and threads them
   through ``metrics.record_llm_call`` — same as ``call()`` already
   does. Without this the new ``hindsight.llm.tokens.cached_input`` and
   ``hindsight.llm.tokens.thoughts`` counters would never report the
   reflect-side share of cached/thinking tokens.

Tests (3 new on top of the 12 from earlier on this branch)
----------------------------------------------------------
- ``test_fingerprint_changes_with_tools``: adding a tool changes the
  fingerprint so a loop that adds a tool gets a fresh cache.
- ``test_fingerprint_stable_under_dict_reordering``: dict-key order in
  the OpenAI-style tools list does NOT change the fingerprint.
- ``test_get_or_create_passes_tools_to_create``: the ``caches.create``
  call actually receives the tools in its config — without this the
  cache would silently lack the tool definitions and the first
  ``call_with_tools(cached_content_name=...)`` would 400.

Verification
------------
- ``uv run pytest tests/test_gemini_cache.py tests/test_gemini_safety_settings.py``
  → 28/28 pass (15 cache + 13 safety; the safety-settings suite
  doubles as regression on the ``call_with_tools`` signature change).
- ``uv run ruff check`` on changed files — clean.

Behavioural envelope
--------------------
- Flag still defaults False — no caller is opted in by default.
- When flag is True, both ``retain_extract_facts`` (from the earlier
  commit on this branch) and ``reflect_tool_call`` opt in.
- A cache-side failure (transient SDK error, prefix too small, manager
  uninstantiated) returns None and the caller proceeds uncached. There
  is no path by which caching can break reflect or retain.

* fix(gemini): make explicit prompt caching actually work end-to-end

The caching paths could never produce a cache hit:

- CreateCachedContentConfig was given response_schema/response_mime_type,
  which the google-genai SDK forbids (extra_forbidden) — so every cache
  create raised and soft-fell-back to an uncached call. Cache only holds
  system_instruction (+ tools); response_schema is a generation-time
  constraint and stays on the per-request GenerateContentConfig.
- call() dropped response_schema when a cache was in use (assuming the
  schema lived in the cache — impossible). Keep it on the request; only
  system_instruction moves into the cache. Structured output is preserved.
- cached_content_name was plumbed into the leaf GeminiLLM.call /
  call_with_tools but NOT through the LLMProvider wrapper, so the real call
  path raised "unexpected keyword argument 'cached_content_name'". Thread it
  through both wrappers, forwarding only when set (other providers untouched).

With these, retain extraction caches the ~1.7k-token prefix at ~90%.

* feat(gemini): cache consolidation prefix + gate reflect cache to auto turns

- Consolidation: split the batch prompt into a stable system instruction
  (mission + rules + decision guide + output format) and a per-batch user
  message (facts + existing observations + capacity note). The system prefix
  is byte-identical across batches in a run, so it is cached and reused; the
  variable data and the per-batch response_schema stay out of the cached
  surface so it never busts. Measures ~30-40% cached/input per batch (the
  remainder is irreducible per-batch data).
- Reflect: Gemini rejects cached_content alongside a per-request tool_config
  ("CachedContent can not be used with ... tool_config"). The forced-retrieval
  iterations set tool_config, so only the `auto` iterations can reference the
  cache. Gate cached_content_name on tool_choice == "auto"; forced iterations
  send the prefix inline.

* test(gemini): per-operation cached-ratio test + consolidation split coverage

- New tests/test_gemini_implicit_cache_ratio.py: measures cached/input token
  ratio per operation (retain, reflect, consolidation) against real Gemini via
  the LLM-request tracer. Dual mode: default records the implicit-cache baseline
  (~0% for this access pattern); HINDSIGHT_GEMINI_EXPLICIT_CACHE=1 asserts the
  explicit cache engages (cached_tokens > 0, per-op ratio floor). Gated behind
  HINDSIGHT_RUN_GEMINI_EVALS=1 + a Gemini key.
- test_consolidation.py: unit test for the system/user prompt split (cacheable
  byte-stable prefix; data only in the user message). Fix the inline mock LLM
  callbacks to read facts from the user message(s) rather than messages[0], now
  that the stable instructions are a separate system message.

* perf(consolidation): move stable observation-format note into cached prefix

The "## INPUT FORMAT" boilerplate (the explanation of the observation JSON
shape: id/text/proof_count/occurred_*/source_memories) was re-sent in every
per-batch user message. It's stable, so move it into the cached system prefix
(build_consolidation_system_prompt); the per-batch user message now carries
only the variable facts + observations data. Lifts the cached/input ratio a
couple of points without changing what the model sees.

* feat(gemini): make cached prefix bank-agnostic (mission → user message)

The retain and consolidation system prompts embedded the per-bank mission, so
each distinct mission produced a different cache fingerprint → one CachedContent
per bank. With many banks/missions that multiplies create + storage cost and
cached-object count, and makes default-on uneconomical.

Move the mission out of the cached prefix into the per-request user message:
- retain: _build_extraction_prompt_and_schema now returns a bank-agnostic prompt;
  the mission rides in the user message via _retain_mission_preamble().
- consolidation: build_consolidation_system_prompt drops the mission param; the
  mission moves into build_consolidation_input (the user message).

Result: the cached prefix is identical across all banks, so a single shared
CachedContent serves every bank — cardinality drops from O(missions) to O(1) per
operation, and the cost-inversion for many-low-volume-bank workloads goes away.

Behavioral note: the mission now appears in the user turn rather than the system
prompt. Validate mission-adherence against the accuracy benchmarks before flipping
the global default on. Tests updated to assert the new location + cross-bank
prefix sharing.

* test(retain): assert different missions yield one shared cache prefix

Extend the mission-relocation test to prove the payoff directly: two banks with
different retain missions produce a byte-identical system prompt → the same cache
fingerprint → a single shared CachedContent instead of one per mission.

* test(retain): cacheable prefix invariant to per-bank free-text (concise/verbose)

Parametrized over the concise and verbose modes: the cached system prompt must be
byte-identical regardless of the retain mission (any value, incl. JSON/unicode/
long text) and custom instructions, so per-bank free-text can never fragment the
shared Gemini cache. Structural toggles (causal/labels/language) are intentionally
out of scope — they legitimately partition the cache via the fingerprint.

* refactor(llm): make prompt-prefix caching a provider-interface feature

Hoist caching out of Gemini-specific duck-typing into the LLMInterface contract,
mirroring supports_batch_api():
- LLMInterface.supports_prompt_caching() -> bool (default False) and
  get_or_create_cached_prefix(...) -> str | None (default None), with docs on how
  explicit-cache (Gemini handle), automatic-cache (OpenAI), and inline-marker
  (Anthropic cache_control) providers each map onto the hook.
- call()/call_with_tools() gain a provider-neutral cached_prefix handle (renamed
  from the Gemini-flavoured cached_content_name); the wrapper forwards it only
  when set so non-caching providers' signatures are untouched.
- GeminiLLM implements supports_prompt_caching(); the retain/consolidation/reflect
  call sites gate on it instead of hasattr().

The engine already decides WHAT is cacheable (bank-agnostic system prefix), so a
new provider only implements HOW — e.g. OpenAI can benefit with no code (stable
leading prefix is auto-cached) or a thin override.

* docs(models): add per-provider capability table (batch API, prompt caching)

Adds a "Provider Capabilities" table to the LLM section of the models page
showing which providers support the Batch API (OpenAI/Groq/Fireworks) and
explicit prompt-prefix caching (Gemini/Vertex via CachedContent), with notes on
OpenAI's automatic prefix caching and the bank-agnostic shared-cache design.
Includes the regenerated skills/hindsight-docs mirror.

* docs(models): drive provider capability table from llmProviders.json

Replace the hand-written capability table with a data-driven one so adding a
provider stays a single-file edit. The capability flags (batchApi, promptCaching)
live in llmProviders.json — the existing single source of truth for the provider
grid and default-models table — and a new LLMProviderCapabilities component (plus
a matching renderer in generate-docs-skill.sh) renders them. Tool-calling dropped
(not differentiating here). Keep flags aligned with supports_batch_api() /
supports_prompt_caching() on the provider classes.

* feat(llm): generic, default-on prompt caching knob

Rename the Gemini-specific opt-in flag to a provider-agnostic, default-on knob,
modelled on HINDSIGHT_API_RETAIN_BATCH_ENABLED:

- HINDSIGHT_API_LLM_GEMINI_PROMPT_CACHE_ENABLED → HINDSIGHT_API_LLM_PROMPT_CACHE_ENABLED
  (config field llm_gemini_prompt_cache_enabled → llm_prompt_cache_enabled, kwarg
  gemini_prompt_cache_enabled → prompt_cache_enabled), single global knob (not per-op).
- DEFAULT_LLM_PROMPT_CACHE_ENABLED = True. Safe to default on: the cached prefix is
  bank-agnostic (one shared cache) and creation soft-fails to an uncached call, so
  it never breaks a request. Providers that don't implement caching ignore the flag.
- Resolve the flag for every provider (drop the gemini/vertexai restriction) so any
  future provider that implements supports_prompt_caching() picks it up.

Docs: models page now says "on by default; disable with
HINDSIGHT_API_LLM_PROMPT_CACHE_ENABLED=false". The per-operation ratio test sets the
flag explicitly in both modes since the default is now on. Includes the regenerated
skills/hindsight-docs mirror.

* fix(gemini): fall back to uncached on a cached-request 400

A 400 from a generate request that references a CachedContent (expired/deleted
cache, cross-project mismatch, cache+tool_config incompatibility, ...) was treated
as a generic retryable error: the same cached request was retried, 400'd again,
and the whole operation failed. The soft-fallback only covered cache *creation*,
not the call that *uses* the cache.

Now, on the first 400 while a cache is in use, call()/call_with_tools():
- drop the cache and rebuild the request inline (re-send system prefix + schema/
  tools) so the request still succeeds,
- invalidate the dead cache name (GeminiCacheManager.invalidate) so the next
  operation recreates it instead of reusing the bad name,
- retry immediately (no backoff — it's a config switch, not a transient error).

If the uncached retry also 400s it's a genuine bad request and errors normally.

Supporting fix: system_instruction is now ALWAYS captured from the messages (it
was skipped when cached), so the fallback has the prefix to inline; the config
builder still omits it from the request while the cache carries it. New unit test
covers the 400 → uncached-retry → invalidate path. Cached success path unchanged
(real Gemini retain still 90.8%).

* fix(gemini): bound the cache-create call with a timeout

get_or_create holds the manager lock across the caches.create network call, which
correctly dedups concurrent callers (a 10-chunk retain batch produces exactly one
create, not ten). But with no timeout, a hung create would block every waiting
chunk indefinitely. Wrap the create in asyncio.wait_for (30s default, configurable
via create_timeout_seconds); on timeout it soft-fails to None and callers proceed
uncached instead of stalling the batch. Unit test covers the timeout path.

* style: ruff-format the prompt-cache config line (fixes verify-generated-files)

* test: fix consolidation-scope-parallelism mock + metrics counter count

- test_consolidation_scope_parallelism.py: the inline mock read facts from
  messages[0], which is now the (cached) system message after the consolidation
  prompt split — read the user message(s) instead.
- test_metrics.py: mock_meter provided 5 counter mocks but MetricsCollector now
  creates 7 (the cached_input + thoughts counters), so create_counter.side_effect
  ran out (StopIteration at setup). Bump both fixtures to 7.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-06-04 14:11:49 +02:00
Nicolò Boschi 01134047d1 feat(recall): per-strategy retrieval boost via env config (#1974)
Add HINDSIGHT_API_RECALL_STRATEGY_BOOSTS, a single env knob that lets a
deployment prioritise one or more retrieval arms (semantic/bm25/graph/temporal)
over the others using a human priority level — e.g. "graph:high" to strongly
favour graph hits, or "graph:high,semantic:low". Valid levels: low | medium |
high. A strategy listed without a level ("graph") defaults to medium; arms you
don't list keep their normal weight; empty disables the feature.

A named level (not a raw number) is the knob because the boost is applied in
two structurally different places on different score scales:
1. Before the reranker cap, as a weighted-RRF sort key, so boosted-arm
   candidates survive the global candidate budget instead of being trimmed by
   raw RRF score (rank-aware).
2. After the reranker, as a flat additive bump to the final ranking weight.

Level -> per-stage magnitudes (in engine/search/recall_boost.py) are tuned
against real recall traces (LoCoMo bank, 336 merged candidates -> 300-cap,
local ms-marco cross-encoder): the observed cap boundary RRF was ~0.0055, so
the stage-1 multipliers 1/3/6 map to rescue/promote/dominate; the cross-encoder
weight scale is [0,1] and bimodal, so the stage-2 additives 0.05/0.2/0.5 map to
nudge/compete/win-over-most-matches. A guard test keeps the level names in sync
with config. Global, read via get_config(), mirroring
recall_max_candidates_per_source.
2026-06-04 13:49:30 +02:00
Minghao Xiao 6b8fc53d79 fix(search): escape pgroonga BM25 query text (#1966) 2026-06-04 11:29:34 +02:00
Nicolò Boschi 602c9f55e2 feat(transfer): whole-bank export/import for cross-instance migration (#1884) (#1953)
* feat(transfer): admin export-bank command (whole-bank portable archive)

Add 'hindsight admin export-bank --bank <id> [--schema] [--include-history]'
that exports an entire bank to a portable ZIP for migrating it to a new
instance configured with a different embedding model / vector / text-search
backend. No embeddings are written — they are regenerated on import.

The archive is a superset of the documents archive:
  * logical document/fact/observation export (replayed + re-embedded on import);
  * bank config, mental models (vector stripped → re-embed), directives, webhooks
    carried as JSON rows;
  * audit_log / llm_requests only with --include-history.

Every bank-scoped table (BACKUP_TABLES) is classified logical / carried /
history / skipped; test_export_bank_covers_schema fails if a future migration
adds a table without classifying it. Import of the new sections is a follow-up.

Tests: schema-coverage guard + a contents test (archive_type, carried bank
config + webhook, no embeddings, history gated by the flag).

* feat(transfer): import-bank — restore a whole-bank archive (cross-instance migration)

Add the import half of bank migration:
  * transfer.import_bank: restores bank config, then docs/facts/observations
    (re-embedded with the TARGET instance's model via import_documents), then
    mental models, directives, webhooks as verbatim rows. Restores exact state —
    fires no webhooks and triggers no consolidation (observations/mental models
    are restored, not regenerated). _restore_rows coerces JSON values back to
    column types (timestamps/uuids/jsonb) and is idempotent (ON CONFLICT DO NOTHING).
  * MemoryEngine.import_bank_async / export_bank_async wrappers.
  * admin 'import-bank' command (boots a MemoryEngine for the target model);
    plus engine-backed export.

Tests: exact round-trip (export -> delete -> import) asserts every section —
bank config, documents, facts, observations, entities, temporal links, webhooks,
directives, mental models — matches exactly, with facts re-embedded (no NULL
vectors). Semantic links compared loosely (ANN index regenerated). Also a guard
that import-bank rejects a documents-only archive.

* docs(transfer): bank migration runbook (export-bank / import-bank)

Document the admin export-bank/import-bank commands and the blue-green runbook
for moving a bank to a new instance with a different embedding model / vector /
text-search backend, re-embedding on import without LLM re-extraction.

* refactor(transfer): drop unused export_bank_async engine method

Code-review: the engine wrapper had no caller but the test — the export-bank CLI
reads rows directly via transfer.export_bank (no engine/embeddings boot needed
for a read-only export). Call transfer.export_bank directly in the test instead.

* docs(transfer): document export-bank/import-bank + migration playbook on the Admin CLI page

Use the installed 'hindsight-admin <cmd>' convention (not 'uv run'). Add the full
export-bank/import-bank command reference and blue-green migration runbook to the
Admin CLI page; reduce the memory-banks section to a short summary that links there.

* refactor(transfer): _admin_connect helper + clearer _REPLAYED_TABLES naming

- Extract _admin_connect(db_url); resolve_database_url already handles pg0:// vs
  postgres://, so export-bank no longer re-implements the connect dance inline.
- Rename _LOGICAL_TABLES -> _REPLAYED_TABLES + clarify: entities/unit_entities/
  memory_links/entity_cooccurrences are NOT exported (rebuilt by the import
  pipeline); the bucket only exists for the coverage guard.

* fix(transfer): import-bank requires a non-existent target bank (no merge)

Importing into an existing bank silently merged: bank config kept (ON CONFLICT
DO NOTHING), docs per on_conflict, and mental_models/directives/webhooks added
alongside existing rows. import-bank restores a WHOLE bank, so refuse when the
target already exists — delete it or pass a fresh --target-bank.

Since a fresh target has no document conflicts, drop the now-meaningless
on_conflict knob from import_bank / import_bank_async / the import-bank CLI.

Test: importing an archive whose bank still exists raises.

* test(transfer): add manual two-instance bank-migration e2e script

scripts/dev/e2e-bank-migration.sh spins instance A (bge-small/384) and B
(bge-base/768), retains into A, runs export-bank -> import-bank, and asserts
recall on B returns the migrated fact ranked first with both instances on
different embedding dims. Self-asserting (exits non-zero on failure); not run in
CI (needs two cached models + an LLM key). Verified passing locally.

* test(transfer): drop manual e2e-bank-migration.sh script

Remove the two-instance migration e2e script from the repo (kept as a local-only
dev tool). Engine-level integration tests in test_document_transfer.py cover the
export/import round-trip.

* docs(admin-cli): add 'Running the CLI' intro (how to run, what it points to)

Explain that hindsight-admin connects directly to PostgreSQL (not the HTTP API),
uses the same config/.env as the API (HINDSIGHT_API_DATABASE_URL), is PostgreSQL-only,
and is typically run inside the API host/container (docker exec / kubectl exec).
2026-06-04 11:26:48 +02:00
Nicolò Boschi e1d5db5c59 fix(test): use current default model in Vertex AI integration test (#1972)
gemini-2.0-flash-001 was retired on Vertex AI (404 NOT_FOUND),
failing the live integration test. Switch to google/gemini-2.5-flash-lite,
matching the vertexai provider default in config.py.
2026-06-04 10:55:11 +02:00
Nicolò Boschi 2535db2745 fix(retain): make document lock/upsert dialect-aware for Oracle (#1944) (#1952)
The retain document-ownership gate used a single
`INSERT ... ON CONFLICT DO UPDATE ... RETURNING content_hash` upsert to
create-or-lock the document row and read its prior hash. PostgreSQL runs
this as-is, but the Oracle adapter rewrites `ON CONFLICT DO UPDATE` to a
`MERGE`, which cannot carry a `RETURNING` clause. The rewritten statement
returned no rows, so every retain 500'd with
`DPY-1003: the executed statement does not return rows`, turning the
`test-python-client-oracle` and `test-typescript-client-oracle` jobs red.

Move the lock-and-read step behind `DataAccessOps.lock_document_for_write`
so each backend implements it natively:
- PG: the same single-statement upsert (DO UPDATE always takes the row
  lock, avoiding the old two-step deadlock).
- Oracle: an idempotent insert (IGNORE_ROW_ON_DUPKEY_INDEX) followed by a
  `SELECT ... FOR UPDATE`, since MERGE can't RETURNING.

Adds regression tests: PG functional coverage of the placeholder→hash
transition and bank isolation, plus translator tests pinning the root
cause (MERGE drops RETURNING) and the Oracle fallback's clean rewrite.
2026-06-04 10:37:22 +02:00
Nicolò Boschi 613a699e9f fix(consolidation): eliminate duplicate observations via interleave dedup recall (#1907)
Round-robin interleave fusion for consolidation dedup recall (guarantees the semantic-#1 'twin' a slot so the LLM updates instead of duplicating), unified 'reranking' strategy param (cross_encoder/rrf/interleave), case-sensitive exact-dup guard, obs-dedup tool + benchmark wired into the perf dashboard (English dataset). Near-dup observation rate 4% -> 0% on the English hermes transcript (1/10 and 1/4), coverage 89% -> 94%, no false merges.
2026-06-03 17:37:57 +02:00
Nicolò Boschi 2834192800 feat(control-plane): "not enabled" splash for disabled audit logs & LLM requests (+ bank name fix) (#1950)
* feat(control-plane): show "not enabled" splash for disabled audit logs & LLM requests

Add a reusable FeatureNotEnabled component (centered icon + title +
description) and use it for the Audit Logs and LLM Requests tabs, plus
refactor the existing Observations splash to reuse it. Tabs gain an
"Off" badge when the feature is disabled.

To let the UI detect server-side gating, expose audit_log and llm_trace
in the /version features object (sourced from config.audit_log_enabled /
config.llm_trace_enabled), wire them through the features context and the
control-plane SDK type, and add i18n keys across all 10 locales.

* fix(retain): default bank name to bank_id in ensure_bank_exists

ensure_bank_exists inserted banks without a name (NULL), unlike the other
creation path (get_or_create_bank_profile, which defaults name to bank_id).
Since #1940 wired PATCH /config to ensure_bank_exists, a config PATCH on a
never-retained bank (and any retain-only bank) produced a NULL name, which
then 500'd the deprecated GET /profile endpoint (name is typed as a required
str). Default name to bank_id at insert so every creation path is consistent.

Extends the #1940 regression test to assert the auto-created bank's profile
returns 200 with name == bank_id.

* test(api): assert audit_log and llm_trace flags in /version response

* chore: regenerate openapi spec and client SDKs for new feature flags
2026-06-03 17:01:59 +02:00
Ben 1b3925f22f blog: Voice Agents That Remember — Adding Memory to Vapi with Hindsight (#1949)
* blog: Vapi Persistent Memory — Phone Agents That Remember Every Caller
2026-06-03 10:56:56 -04:00
Nicolò Boschi 1d6d73bce4 feat(transfer): export/import documents between banks without re-running the LLM (#1909)
* feat(transfer): export/import documents between banks without re-running the LLM

Export a bank's already-extracted facts (text, entity canonical names, causal
links, chunks) to a ZIP archive, and import them into another bank by replaying
the deterministic half of the retain pipeline — re-embedding locally with the
target bank's model and re-resolving entities. No LLM fact extraction runs on
import. Consolidated observations are excluded (regenerated by consolidation in
the target bank).

Two use cases: testing a different embedding model, and moving data between
banks/instances without LLM cost.

- engine/transfer/: schema, export, importer (LLM-free replay)
- MemoryEngine.export_documents_async / import_documents_async
- Admin CLI: export-documents / import-documents
- HTTP API: GET/POST /v1/default/banks/{bank_id}/document-transfer
- Gated by HINDSIGHT_API_ENABLE_DOCUMENT_EXPORT_API / _IMPORT_API
  (default on), surfaced via /version features for the control plane
- Control plane Documents page: Export All / Import (zip upload) +
  per-document Export, hidden when the backend disables the feature
- Tests, docs, regenerated OpenAPI spec and client SDKs

* fix(transfer): trigger consolidation, graph maintenance & webhooks on import

Imported documents were second-class citizens: unlike a normal retain, an
import fired no retain.completed webhooks and never enqueued consolidation
or graph maintenance, so imported facts never produced observations.

Thread an outbox callback factory through import_documents -> _import_one_document
so each imported document fires its retain.completed webhook transactionally
inside its own insert. After the import completes, submit async consolidation
(when observations + auto-consolidation are enabled) and graph maintenance,
mirroring the post-retain side effects.

* refactor(transfer): share post-insert maintenance helper between retain and import

The consolidation + graph-maintenance triggers added for import duplicated the
retain post-processing block verbatim. Extract it into
_submit_post_insert_maintenance and call it from both the retain pipeline and
the import pipeline, so the two paths stay in lockstep.

* feat(transfer): fire on_retain_complete per imported document

Import now fires the post-retain extension hook (usage tracking / metrics /
notifications) once per imported document, mirroring retain — so imported
facts are first-class for extensions. Token counts are zero and
processed_content_tokens is 0 (import runs no LLM extraction), so cost-metering
extensions correctly bill an import as free.

The importer returns per-document outcomes (ImportedDocument) so the engine can
build the RetainResult; these are not serialized into the operation's
result_metadata (the worker still writes counts only).

Tests: assert the hook fires once per document with zero tokens, and that
import queues a retain.completed webhook delivery per document.
2026-06-03 16:24:33 +02:00
Nicolò Boschi d695611ada fix(retain): stop bank_id routing key polluting fact attribution (#1680) (#1948)
* fix(retain): stop bank_id routing key polluting fact attribution (#1680)

The fact extractor injects a 'Narrator: {banks.name}' line that is stamped
into the who-dimension of every first-person fact (and the observations
consolidated from them). On auto-create banks.name defaults to bank_id, which
is typically a routing key (e.g. my-agent::channel-456::user-789), not a
speaker — so the routing key ends up embedded in stored fact text.

- Suppress the narrator when name == bank_id (_resolve_narrator).
- Make the Context take precedence over the narrator for speaker attribution:
  when the Context names a different first-person speaker (a user/customer in a
  transcript), those statements are classified 'world' and attributed to that
  speaker, not the agent.

Tests: pure unit tests for the suppression + injection logic, and a real-LLM
test (llm_judge) verifying user first-person statements are attributed to the
user as 'world'. The agent-self-log behaviour is unchanged.

* fix(retain): only add Context-precedence clause when context is set

The narrator's 'Context above takes precedence' clause referenced a
'Context: none' line when no context was provided. Gate it on context.

* test+docs: judge fact_type classification; document LLM-judge tests and world/experience facts

- test_narrator_context_override: assert fact_type via LLM judge (not a hard
  enum assert), matching the codebase's hs_llm_core pattern.
- CLAUDE.md + code-review skill: document real-LLM + llm_judge tests for any
  change to model-interpreted behaviour (classification, attribution, prompts).
- docs/developer/retain.md: clarify world vs experience facts — the split is
  by speaker; set the bank name and describe the speaker in context.
2026-06-03 16:22:51 +02:00
Maple Gao a14ce623c5 fix(control-plane): localize operations and graph legends (#1946) 2026-06-03 15:29:07 +02:00
Nicolò Boschi a809547aa8 fix(config): persist bank config PATCH for never-retained banks (#1940) (#1945)
Banks are created lazily on first retain, so a PATCH /config that preceded
any ingestion UPDATE-d zero rows and silently no-op'd while returning 200 —
the resolved response then reported global defaults with empty overrides.

Auto-create the bank (reusing ensure_bank_exists, which also creates the
per-bank vector indexes) before merging, and guard the JSONB merge with
COALESCE so a NULL config column doesn't drop the override.

Adds an API-level regression test covering enable_observations and
enable_auto_consolidation round-tripping for an uncreated bank.
2026-06-03 15:08:58 +02:00
Nicolò Boschi 70d98c7a27 fix(recall): gate VectorChord BM25 + add per-source candidate cap (#1707) (#1947)
VectorChord BM25 ranks *every* document via the `<&>` operator (which returns
the negative BM25 score), so a bare `ORDER BY ... LIMIT` padded each recall with
zero-score, non-matching rows. Unlike native tsvector — which has a boolean `@@`
match gate — the vchord arm had no gate, flooding RRF/reranking with weak
candidates and broadening answers (the #1707 regression).

- Gate the vchord BM25 arm on `-(search_vector <&> ...) > bm25_min_score`
  (default 0), the direct analogue of native's `@@` gate. Verified on a real
  VectorChord container: a query that returned 10 rows (2 real matches + 8 rows
  scoring exactly 0.0) now returns only the 2 genuine matches. Oracle's CONTAINS
  gate now shares the same configurable floor (behavior unchanged at 0).
- Add an optional per-source candidate cap applied to each arm (semantic, BM25,
  graph, temporal) before RRF, so one over-expanding backend cannot fill the
  reranker's global budget alone (HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE,
  default 0 = disabled). Verified live: cap=1 trims semantic 10->1, bm25 4->1.

New config: HINDSIGHT_API_BM25_MIN_SCORE, HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE.
2026-06-03 15:05:54 +02:00
Nicolò Boschi b1f6bbb8b4 feat(api): per-bank LLM request tracing via OTel GenAI recorder (#1922)
* feat(api): per-bank LLM request tracing via OTel GenAI recorder

Record every LLM call (success and failure) into a new `llm_requests`
table, per bank, when HINDSIGHT_API_LLM_TRACE_ENABLED=true (disabled by
default). Capture is wired into the OpenTelemetry GenAI record_llm_call
path: the DB tracer is registered as a span recorder alongside the OTLP
exporter, so providers' existing success calls flow through it and the
LLM wrapper forwards failures.

Each row stores input messages, model output, token usage
(input/output/cached/total from the provider response), finish reason,
provider/model/scope, timing, and caller metadata.

- GET /v1/default/banks/{bank}/llm-requests (+ /stats) read API
- Control-plane "LLM Requests" tab: list, filters, detail dialog, and a
  Calls/Tokens chart with Total/Breakdown and Cumulative toggles
- Reusable JsonViewer component (word-wrap + copy), applied to audit logs
- TokenUsage.cached_tokens; cached-token extraction for
  openai-compatible, gemini, anthropic
- Migrations for the table + token columns; backup/restore coverage
- Tests, docs, regenerated OpenAPI + SDK clients

* test(llm-trace): regression test for delta re-retain document_id binding

* feat(llm-trace): map produced/consumed memory_ids to retain & consolidation traces

Retain traces now carry metadata.memory_ids (the facts created); consolidation
traces carry metadata.source_memory_ids (memories consumed) and metadata.memory_ids
(observations created/updated). Accumulated at the DB-write sites onto the
operation-level trace context and flushed onto every row of the trace via
LLMTraceRecorder.attach_memory_ids (awaits in-flight fire-and-forget writes first
so the UPDATE never races ahead of the rows). Surfaced in the trace dialog as
'Memories created' / 'Source memories' chips.

* perf+feat(llm-trace): fire-and-forget mapping + bidirectional memory↔trace

Performance:
- attach_memory_ids is now fire-and-forget — it snapshots ids synchronously and
  patches the trace on a background task, off the retain/consolidation critical
  path. The pending-write flush is scoped to the operation's own trace_id
  (bucketed pending set) so it never waits on unrelated operations.

Memory ↔ trace navigation:
- New memory_id filter on the llm-requests listing, matching metadata.memory_ids
  (produced) OR metadata.source_memory_ids (consumed), so a memory resolves both
  the run that created it and the consolidation runs that used it as a source.
- Memory detail panel shows 'Created by' and 'Used by' sections opening the
  trace dialog. Regenerated OpenAPI spec + SDK clients.

* ui(llm-trace): rename 'Used by' to 'Consolidated by' on memory trace panel

* chore(clients): regenerate SDK clients after merge (llm_requests endpoints)

* ci(cli-coverage): mark llm_requests tracing endpoints UI-only

* fix(control-plane): drop invalid 'as const' on ternary (prod build typecheck)

* fix(llm-trace): guard trace_context() access for mock/substitute providers

run_consolidation_job and retain read the operation trace context off the
configured provider, but tests substitute a bare MockLLM without a
trace_context() method, which AttributeError'd and crashed all consolidation.
Add trace_context_of() to read it defensively (None when unsupported), so
tracing degrades gracefully and never breaks the operation.
2026-06-03 11:26:31 +02:00
Ben 24d6c2a43b blog: Using Entity Labels to Automatically Tag Memories in Hindsight (#1935)
* blog: Using Entity Labels to Automatically Tag Memories in Hindsight

Narrative explainer for the entity-labels feature — the controlled-
vocabulary classification system that runs during the retain pipeline.
Covers the four label types (value / multi-values / text / map), the
JSON-schema-enforced extraction path, the `tag: true` switch that
mirrors labels into memory tags for filterable recall, labels-only
mode, vocabulary-design best practices, and an end-to-end support-
ticket worked example with retain + recall code.

Fills a documentation gap: the feature has been called out in v0.6.1
and v0.7.0 release posts but never had a dedicated narrative piece.
Reference docs and Constellation post are cross-linked.
2026-06-02 15:13:41 -04:00
Nicolò Boschi 23168ebf68 fix(retain): pre-extraction freshness recheck + serialize concurrent same-doc writers (#1930)
Two fixes for concurrent retains targeting the same document:

1. Delta path now re-reads the document hash BEFORE the (expensive) LLM
   extraction. If a concurrent retain already committed identical content, we
   skip extraction and update metadata only; if it still differs we fall back
   to streaming. This avoids burning LLM tokens re-extracting work a concurrent
   request already did (staggered 10-way race: 10 -> 1 extraction call).

2. Streaming write-txn ownership gate is now a single atomic
   INSERT ... ON CONFLICT DO UPDATE (which locks the row) instead of
   INSERT ON CONFLICT DO NOTHING + a separate SELECT FOR UPDATE. DO NOTHING
   does not lock the existing row, which let concurrent same-document writers
   interleave the speculative-insert ShareLock with the later FOR UPDATE and
   cascade-DELETE in inconsistent orders, producing Postgres deadlocks.

Adds tests/test_retain_same_document_concurrency.py covering: identical
concurrent retains skip extraction, partial-overlap race completes cleanly,
staggered race avoids redundant extraction, and fully-different concurrent
retains no longer deadlock.
2026-06-02 18:24:31 +02:00
Nicolò Boschi dd75f0dbc8 chore(control-plane): bump next back to ^16.2.6 (undo 16.2.5 pin) (#1934)
Reverts the temporary `next` pin from #1928. Deeper investigation showed the
control-plane redirect loop (#1926) is NOT a 16.2.6 regression: it reproduces
identically on 16.2.5 and 16.2.6, and is triggered specifically by binding the
standalone server to HOSTNAME=127.0.0.1 (Next normalizes 127.0.0.1 -> localhost
in the proxy request URL but keeps 127.0.0.1 in the router's initUrl, so the
next-intl locale rewrite looks cross-origin and leaks as a 307 loop).

The production launchers (docker start-all.sh, bin/cli.js) bind HOSTNAME=0.0.0.0,
which serves 200 on every version, so the pin neither fixed #1926's repro nor was
needed for production. Restoring ^16.2.6 brings back the 16.2.6 security fixes
(proxy-bypass + SSRF). The 127.0.0.1-binding quirk is unrelated to the version.

Verified: npm ci -> single [email protected]; control-plane build typechecks; standalone
on HOSTNAME=0.0.0.0 serves /login, /banks/*, /es/login as 200.
2026-06-02 17:03:16 +02:00
Octopusandocto-patch d8dadc0a95 feat: upgrade MiniMax default model to M3 (#1914)
- Switch the minimax provider default from MiniMax-M2.7 to MiniMax-M3
  in PROVIDER_DEFAULT_MODELS (hindsight-api-slim/hindsight_api/config.py).
- Update the LiteLLM router test fixture to exercise MiniMax-M3.
- Update provider docstrings and example .env entries to mention MiniMax-M3
  while keeping MiniMax-M2.7 noted as a previous-generation option.
- Refresh hindsight-docs (developer/models, integrations/hermes,
  llmProviders.json) and the docs-skill reference table to list
  MiniMax-M3 as the documented default.

The deprecated MiniMax-M2.5 / M2.1 / M2 / M1 IDs are not referenced
anywhere in the active codebase, so no removals are required.

Co-authored-by: octo-patch <[email protected]>
2026-06-02 16:35:44 +02:00
Nicolò Boschi 401c3cd3fb docs: changelog and blog post for v0.7.2 (#1933)
* docs: changelog and blog post for v0.7.2

* docs: regenerate hindsight-docs skill references for v0.7.2

* docs: trim 0.7.2 blog to Flowise integration with docs link
2026-06-02 16:24:08 +02:00
Ben 7dffc0459d release(google-adk): v0.1.0 2026-06-02 10:02:16 -04:00
Ben f950e0c11c docs(guides): add Hermes memory guide batch (#1932) 2026-06-02 09:57:40 -04:00
Nicolò Boschi ffd7f94572 Release v0.7.2
- Update version to 0.7.2 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- hindsight-all npm wrapper: hindsight-all-npm
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.7
2026-06-02 15:17:05 +02:00
Nicolò Boschi 201f5d7cda fix(control-plane): pin next to 16.2.5 to fix standalone i18n redirect loop (#1926) (#1928)
next 16.2.6 regressed how the standalone server resolves next-intl locale
rewrites. With the standalone default HOSTNAME=0.0.0.0, the i18n rewrite is
emitted as an absolute localhost URL and treated as cross-origin, so every page
route returns a 307 to itself (ERR_TOO_MANY_REDIRECTS). Bisected: 16.2.5 serves
200 with a relative rewrite; 16.2.6 and 16.2.7 loop. next dev is unaffected.

Pin next to 16.2.5 (exact) and add a root override so next-intl's peer dedupes
to the same single version — a 16.2.5/16.2.6 split fails the control-plane
typecheck. The Docker image build resolves the exact pin; CI `npm ci` installs
the pinned lockfile (single hoisted [email protected], all platform binaries kept).

Temporary: 16.2.6 is a security release, so we should return to a patched
version once the regression is fixed upstream. Tracking: vercel/next.js#94342.
2026-06-02 15:02:23 +02:00
Nicolò Boschi 8a1f0461cf docs(docker): drop --rm, add --name + restart policy in run examples (#1927)
A single child segfault under load propagates through start-all.sh and
exits the whole container; with the documented --rm run there was no
recovery. Replace --rm with --name hindsight --restart unless-stopped in
the documented server-run commands so a transient crash self-heals.

Leaves the throwaway --rm --entrypoint sh model-inspection command in
custom-models/README.md untouched. Refs #1918.
2026-06-02 14:19:16 +02:00
Nicolò Boschi 670c2be5e4 refactor(api): move audit-logs endpoint queries into MemoryEngine (#1925)
The /audit-logs and /audit-logs/stats handlers ran raw SQL directly in
the HTTP layer instead of going through a MemoryEngine method, violating
the API-layer data-access standard (queries belong in the engine; auth/
tenancy enforced there). Mirrors the llm-requests pattern from #1922.

- Add list_audit_logs / audit_log_stats engine methods. Both call
  get_bank_profile(create_if_missing=False) first, which runs
  _authenticate_tenant before any query, so the SQL is gated behind the
  same tenant auth every other op uses and scoped to the tenant schema.
- Move the audit response models into engine/audit.py so the engine can
  build and return them; HTTP handlers now just delegate.
- Add tenant-auth regression tests for both reads (invalid API key).

OpenAPI spec unchanged (model names/fields identical).

Closes #1923
2026-06-02 13:08:26 +02:00
Nicolò Boschi 99c7367fc0 perf(graph-maintenance): cast ANN seed embeddings once + add perf suite (#1919) (#1924)
The semantic-ANN relink pass in graph_maintenance was disproportionately
slow on small banks: ~50 seeds over a ~1k-unit bank took 1.5-3.7s and
dominated the whole job (97% of a 27s run).

Root cause: compute_semantic_links_ann stored seeds as text and computed
`mu.embedding <=> s.emb_text::vector` inside the LATERAL, re-parsing the
~5KB embedding string for every candidate row the probe touched
(seeds x bank_units text-parses per batch). Fix: cast each seed to
`vector` exactly once in a MATERIALIZED CTE. Measured ~25-48x faster on
small banks (per-batch ANN 1.47s -> 0.098s; medium job 27.3s -> 2.48s)
and ~2.4x on large banks, where the planner already auto-selects the
per-bank partial HNSW index. Behaviour is unchanged (identical results),
shared with retain Phase 3.

Also adds a `graph-maintenance` perf suite (populate via mock LLM + real
embeddings, delete 10% to enqueue relink victims, run the job, break
wall-clock down by probe) so this path is tracked in the periodic
benchmarks. large scale = 15k units to exercise the HNSW index path;
medium = 1k stays in the exact-scan regime.
2026-06-02 12:30:18 +02:00
Ben e4b50f8054 blog: Building a Hermes Coding Assistant on Windows That Remembers Your Codebase (#1912)
* blog: Running Hermes with Persistent Codebase Memory on Windows

Windows-specific companion to the Hermes coding-assistant codebase memory
post. Covers the native install path (no Docker, no WSL), the PYTHONUTF8
setup that mirrors the Windows CI smoke test, three coding workflows where
Hermes + Hindsight pays off on Windows, and the common Windows gotchas
(UTF-8 encoding, pg0 init time, long paths, Defender on the embedded
Postgres binary).

* blog: reframe Windows post around Nous's native-Windows announcement

- Retitle to "Hermes Agent on Windows: Add Persistent Codebase Memory
  with Hindsight" so the post reads as the news-companion piece.
- Lead with the Nous Research announcement (yesterday) and frame
  Hindsight as the memory layer that pairs with their freshly-shipped
  native Windows support.
- Tighten the Windows-gap paragraph and move the smoke-test callout
  later so it lands as "we were ready, now Hermes is too" rather than
  background scaffolding.
- Replace closing line to echo the news angle.
- Swap placeholder cover for the Windows x Hermes branded card.

* blog(windows): update cover image

* blog(windows): simplify setup to one command + mode picker

The actual Windows setup is just `hermes memory setup` plus the mode
selection prompt. Rewrite the section around the wizard's three modes
(Cloud / Local Embedded / Local External) instead of the old four-step
install dance, drop the pip-install pre-step (Local Embedded fetches
hindsight-embed via uvx automatically), and move the UTF-8 step out of
setup into the Gotchas section where it's self-contained. Also reframe
the "Local Mode" section as a mode-picker decision tree.

* blog(windows): swap cover image for coding post

* blog(windows): retitle to mirror the proven Hermes coding-post formula
2026-06-01 16:11:21 -04:00
Ben bddd22a852 blog: Hermes Agent on Windows — Set Up Persistent Memory with Hindsight (#1913)
Platform-neutral companion to the coding-focused Windows post. Same
news hook (Nous shipped Hermes native on Windows yesterday), same
one-command setup and three-mode picker, but framed around the broader
Hermes use cases: personal-assistant continuity, the Hermes Gateway
sharing one memory bank across Telegram/Discord/Slack, and long-running
research/writing projects.

Cross-links to the coding post via the public hindsight.vectorize.io URL
so the build-docs onBrokenLinks check doesn't fire before the coding
post merges.
2026-06-01 15:38:58 -04:00
Ben c032a74f17 feat(google-adk): add Hindsight integration for Google ADK (#1862)
* feat(google-adk): add Hindsight integration for Google ADK

Implements google.adk.memory.BaseMemoryService so Runner-driven agents
get persistent long-term memory automatically:

- HindsightMemoryService — retain on session end, recall on search_memory,
  with per-(app_name, user_id) bank scoping via a configurable template
- create_hindsight_tools — ADK FunctionTool wrappers for explicit
  hindsight_retain / hindsight_recall / hindsight_reflect

49/49 tests pass. CI job, release script, and changelog generator wired up.
Docs page + integrations.json + banner + sidebar entry added.

* feat(google-adk): add ADK icon from adk.dev

* test(google-adk): add end-to-end smoke script with real Gemini Runner

Exercises both integration patterns against the dev cloud:

- Phase 1: HindsightMemoryService (automatic memory) — Runner saves
  session A via add_session_to_memory; session B's agent calls
  load_memory which routes through search_memory and gets the facts back.
- Phase 2: create_hindsight_tools (explicit) — agent calls hindsight_retain
  directly in session C; session D's agent calls hindsight_recall.

Both phases pass live against api.dev.hindsight.vectorize.io with
gemini-2.0-flash.

* fix(google-adk): apply repo ruff format to smoke_runner.py
2026-06-01 13:39:43 -04:00
Nicolò Boschi a4650f2da5 chore(dev): one-shot dev setup script + fix control-plane production build (#1910)
* fix(control-plane): force NODE_ENV=production for production build

A globally-exported NODE_ENV=development (common in dev shells) overrides
Next.js's production default during `next build`, bundling React's development
build under the production server renderer. Static prerendering then crashes
with "Cannot read properties of null (reading 'useContext')" — even on the
built-in _global-error page.

Pin NODE_ENV=production for the build step so it is robust regardless of the
caller's shell. Docker is unaffected (it invokes next build directly in a clean
env).

* chore(dev): add one-shot dev environment setup script

Add scripts/dev/setup.sh: an idempotent bootstrap that installs the required
toolchains (uv/Python, Node/npm, Rust/cargo) when missing, creates .env,
configures git hooks, installs all Python + Node workspace deps, pre-downloads
the local ML models + tokenizer for offline use, and builds the TypeScript SDK
and Rust CLI. Flags: --skip-build, --skip-models, --with-docs, --force.

Document it in CONTRIBUTING.md as the recommended setup, keeping the manual
steps as a fallback.
2026-06-01 18:18:38 +02:00
708 changed files with 59954 additions and 8163 deletions
+20
View File
@@ -73,6 +73,11 @@ results = await asyncio.gather(*tasks, return_exceptions=True)
results = await asyncio.gather(*tasks, return_exceptions=True)
```
### API Layer & Data Access
- **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).
### Branch Hygiene
- **Always start new feature branches from `origin/main`** — rebase to ensure a clean base.
- **Only include commits relevant to the PR/branch/feature** — no unrelated changes. If the branch contains commits that don't belong, they must be removed before merging.
@@ -135,6 +140,13 @@ For each new or significantly changed function/endpoint/class:
Flag any new logic that lacks test coverage.
**LLM-behaviour changes need a real-LLM judge test, not MockLLM.** If the change alters how the model interprets a prompt — fact/observation extraction, `fact_type` (world/experience) classification, speaker attribution, instruction-following, prompt wording — there MUST be a test marked `pytest.mark.hs_llm_core` that runs the real pipeline and asserts via `tests.llm_judge.assert_meets_criteria` (not string/enum matching). Flag these as findings:
- A prompt/classification change verified only by MockLLM or string assertions (MockLLM echoes input — such tests pass spuriously). **Should fix.**
- A test that hard-asserts `fact_type == "world"/"experience"` (or other model-decided output) instead of judging it — non-deterministic, will flake across providers/runs. **Should fix** (move the classification check into the judge `criteria`; keep only genuinely deterministic structural asserts direct).
- Deterministic mechanics (prompt assembly, suppression/branching logic) that are covered *only* by a slow LLM test — these should also have fast non-LLM unit tests. **Note.**
See CLAUDE.md → Key Conventions → Testing for the full pattern.
### 7. Check API consistency
If any files in `hindsight-api-slim/hindsight_api/api/` were changed:
@@ -142,6 +154,12 @@ If any files in `hindsight-api-slim/hindsight_api/api/` were changed:
- Were the client SDKs regenerated? (`./scripts/generate-clients.sh`)
- Were the control plane proxy routes updated? (`hindsight-control-plane/src/app/api/`)
### 7b. Check API-layer data-access boundary
For each changed handler in `hindsight-api-slim/hindsight_api/api/` (e.g. `http.py`, `mcp.py`):
- **Flag any direct DB access in the handler** — `acquire_with_retry`, `conn.fetch` / `fetchrow` / `execute`, raw SQL strings, or `fq_table(...)`. These are a **must fix**: the query must be moved into a `MemoryEngine` method that returns a typed model, and the handler must call that method.
- **Verify authentication is enforced in the engine** — the handler must delegate to an engine method that authenticates via `request_context` (`_authenticate_tenant`, typically through `get_bank_profile`). A handler that reads/writes tenant-scoped data without an engine method enforcing auth is a **must fix** (tenant data could leak across schemas).
### 8. Check code comments
For each non-trivial change:
@@ -196,6 +214,8 @@ Present a clear summary organized by severity:
- Raw dict usage for structured data (including internal code)
- Multi-item tuple returns (including internal code)
- Missing tests for new endpoints
- Direct DB access (raw SQL / `acquire_with_retry` / `fq_table`) in an `api/` handler instead of a `MemoryEngine` method
- Tenant-scoped data accessed without authentication enforced in the engine (`_authenticate_tenant` / `get_bank_profile`)
- New integration missing tests, CI job, or release-integration.sh entry
- New PostgreSQL table missing from `BACKUP_TABLES` in `admin/cli.py` (silent data loss on restore)
+15 -2
View File
@@ -25,7 +25,7 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# Example: MiniMax configuration (1M context window)
# HINDSIGHT_API_LLM_PROVIDER=minimax
# HINDSIGHT_API_LLM_API_KEY=your-minimax-api-key
# HINDSIGHT_API_LLM_MODEL=MiniMax-M2.7
# HINDSIGHT_API_LLM_MODEL=MiniMax-M3 # or MiniMax-M2.7 for the previous generation
# Example: DeepSeek configuration (https://api.deepseek.com)
# HINDSIGHT_API_LLM_PROVIDER=deepseek
@@ -80,10 +80,23 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER=
# Embeddings Configuration (Optional - uses local by default)
# Provider: "local" (default), "tei", "openai", "cohere", "google", "openrouter", "zeroentropy", "litellm", or "litellm-sdk"
# Provider: "local" (default), "onnx", "tei", "openai", "cohere", "google", "openrouter", "zeroentropy", "litellm", or "litellm-sdk"
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
# For local provider:
# HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-small-en-v1.5
# For ONNX provider (local CPU embeddings without an Ollama/TEI sidecar):
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=onnx
# HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_ID=intfloat/multilingual-e5-small
# HINDSIGHT_API_EMBEDDINGS_ONNX_FILE=onnx/model.onnx
# HINDSIGHT_API_EMBEDDINGS_ONNX_DIMENSIONS=384
# HINDSIGHT_API_EMBEDDINGS_ONNX_MAX_TOKENS=512
# HINDSIGHT_API_EMBEDDINGS_ONNX_POOLING=mean
# HINDSIGHT_API_EMBEDDINGS_ONNX_NORMALIZE=true
# HINDSIGHT_API_EMBEDDINGS_ONNX_QUERY_PREFIX="query: "
# HINDSIGHT_API_EMBEDDINGS_ONNX_PASSAGE_PREFIX="passage: "
# Optional for local model paths or pre-downloaded artifacts:
# HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_PATH=/models/multilingual-e5-small/onnx/model.onnx
# HINDSIGHT_API_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH=/models/multilingual-e5-small
# Optional for China network / restricted HF access:
# HF_ENDPOINT=https://hf-mirror.com
# For TEI provider:
+96
View File
@@ -24,6 +24,7 @@ on:
- recall
- recall-with-observations
- consolidation
- graph-maintenance
default: ""
locomo_conversations:
description: "LoComo conversation IDs (space-separated). Blank = curated set (conv-26 conv-30 conv-43)."
@@ -33,6 +34,18 @@ on:
description: "Skip LoComo job"
type: boolean
default: false
obs_skip:
description: "Skip observation-dedup benchmark job"
type: boolean
default: false
obs_dataset:
description: "Obs benchmark dataset substring (blank = English hermes transcript)."
type: string
default: ""
obs_fraction:
description: "Obs benchmark fraction (0-1] of each document to run."
type: string
default: "1.0"
ref:
description: "Git ref to test (branch, tag, or SHA). Defaults to main."
type: string
@@ -198,3 +211,86 @@ jobs:
PERF_DASHBOARD_TOKEN: ${{ secrets.PERF_DASHBOARD_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./scripts/benchmarks/publish-locomo-results.sh hindsight-dev/benchmarks/locomo/results/benchmark_results.json
obs:
# Observation-dedup quality benchmark: ingests a transcript, drains consolidation
# (serial SyncTaskBackend + embedded pg0 — no external DB / worker), and reports the
# near-duplicate observation rate. Real LLM via VertexAI, mirroring the LoComo job.
if: inputs.obs_skip != true
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
HINDSIGHT_API_ENABLE_OBSERVATIONS: "true"
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup GCP credentials
run: |
printf '%s' '${{ secrets.GCP_VERTEXAI_CREDENTIALS }}' > /tmp/gcp-credentials.json
PROJECT_ID=$(jq -r '.project_id' /tmp/gcp-credentials.json)
echo "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=$PROJECT_ID" >> $GITHUB_ENV
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Cache HuggingFace models
uses: actions/cache@v5
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api-slim/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api-slim
run: |
uv run --frozen --all-extras --index-strategy unsafe-best-match python -c "
from sentence_transformers import SentenceTransformer
print('Downloading embedding model...')
SentenceTransformer('BAAI/bge-small-en-v1.5')
print('Model downloaded successfully')
"
- name: Install hindsight-dev dependencies
run: |
cd hindsight-dev && uv sync --frozen --all-extras --index-strategy unsafe-best-match
- name: Run obs benchmark
# Default to the English hermes transcript at full fraction — a clean, deterministic
# consolidation-dedup signal (the Chinese variant adds a cross-lingual embedding
# confound). Override dataset/fraction via workflow_dispatch.
run: |
DATASET="${{ inputs.obs_dataset }}"
if [ -z "$DATASET" ]; then DATASET="hermes_session_2026-05-15_en"; fi
FRACTION="${{ inputs.obs_fraction }}"
if [ -z "$FRACTION" ]; then FRACTION="1.0"; fi
cd hindsight-dev
uv run python -m benchmarks.obs.obs_benchmark \
--dataset "$DATASET" --fraction "$FRACTION" --wipe-bank --output obs-results.json
- name: Upload obs results
if: always()
uses: actions/upload-artifact@v7
with:
name: obs-results-${{ github.sha }}
path: hindsight-dev/obs-results.json
retention-days: 90
- name: Publish obs to dashboard
if: success() && (github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'workflow_dispatch')
env:
PERF_DASHBOARD_TOKEN: ${{ secrets.PERF_DASHBOARD_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./scripts/benchmarks/publish-obs-results.sh hindsight-dev/obs-results.json
+222 -3
View File
@@ -39,20 +39,25 @@ jobs:
integrations-litellm: ${{ steps.filter.outputs.integrations-litellm }}
integrations-pydantic-ai: ${{ steps.filter.outputs.integrations-pydantic-ai }}
integrations-ag2: ${{ steps.filter.outputs.integrations-ag2 }}
integrations-autogen: ${{ steps.filter.outputs.integrations-autogen }}
integrations-langgraph: ${{ steps.filter.outputs.integrations-langgraph }}
integrations-llamaindex: ${{ steps.filter.outputs.integrations-llamaindex }}
integrations-paperclip: ${{ steps.filter.outputs.integrations-paperclip }}
integrations-opencode: ${{ steps.filter.outputs.integrations-opencode }}
integrations-n8n: ${{ steps.filter.outputs.integrations-n8n }}
integrations-cloudflare-oauth-proxy: ${{ steps.filter.outputs.integrations-cloudflare-oauth-proxy }}
integrations-superagent: ${{ steps.filter.outputs.integrations-superagent }}
integrations-lockfiles: ${{ steps.filter.outputs.integrations-lockfiles }}
integrations-openai-agents: ${{ steps.filter.outputs.integrations-openai-agents }}
integrations-pipecat: ${{ steps.filter.outputs.integrations-pipecat }}
integrations-agentcore: ${{ steps.filter.outputs.integrations-agentcore }}
integrations-smolagents: ${{ steps.filter.outputs.integrations-smolagents }}
integrations-claude-agent-sdk: ${{ steps.filter.outputs.integrations-claude-agent-sdk }}
integrations-dify: ${{ steps.filter.outputs.integrations-dify }}
integrations-gemini-spark: ${{ steps.filter.outputs.integrations-gemini-spark }}
integrations-vapi: ${{ steps.filter.outputs.integrations-vapi }}
integrations-flowise: ${{ steps.filter.outputs.integrations-flowise }}
integrations-google-adk: ${{ steps.filter.outputs.integrations-google-adk }}
tools-agent-sdk: ${{ steps.filter.outputs.tools-agent-sdk }}
integrations-roo-code: ${{ steps.filter.outputs.integrations-roo-code }}
dev: ${{ steps.filter.outputs.dev }}
@@ -124,6 +129,10 @@ jobs:
- 'hindsight-integrations/pydantic-ai/**'
integrations-ag2:
- 'hindsight-integrations/ag2/**'
integrations-autogen:
- 'hindsight-integrations/autogen/**'
integrations-langgraph:
- 'hindsight-integrations/langgraph/**'
integrations-llamaindex:
- 'hindsight-integrations/llamaindex/**'
integrations-paperclip:
@@ -134,6 +143,8 @@ jobs:
- 'hindsight-integrations/n8n/**'
integrations-cloudflare-oauth-proxy:
- 'hindsight-integrations/cloudflare-oauth-proxy/**'
integrations-superagent:
- 'hindsight-integrations/superagent/**'
integrations-lockfiles:
- 'hindsight-integrations/*/package-lock.json'
- 'hindsight-integrations/*/package.json'
@@ -146,6 +157,8 @@ jobs:
- 'hindsight-integrations/agentcore/**'
integrations-smolagents:
- 'hindsight-integrations/smolagents/**'
integrations-claude-agent-sdk:
- 'hindsight-integrations/claude-agent-sdk/**'
integrations-dify:
- 'hindsight-integrations/dify/**'
integrations-gemini-spark:
@@ -154,6 +167,8 @@ jobs:
- 'hindsight-integrations/vapi/**'
integrations-flowise:
- 'hindsight-integrations/flowise/**'
integrations-google-adk:
- 'hindsight-integrations/google-adk/**'
tools-agent-sdk:
- 'hindsight-tools/hindsight-agent-sdk/**'
integrations-roo-code:
@@ -728,6 +743,43 @@ jobs:
working-directory: ./hindsight-integrations/pipecat
run: uv run pytest tests -v
test-google-adk-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-google-adk == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build google-adk integration
working-directory: ./hindsight-integrations/google-adk
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/google-adk
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/google-adk
run: uv run pytest tests -v
test-gemini-spark-integration:
needs: [detect-changes]
if: >-
@@ -2761,6 +2813,45 @@ jobs:
working-directory: ./hindsight-integrations/ag2
run: uv run pytest tests -v
test-autogen-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-autogen == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build autogen integration
working-directory: ./hindsight-integrations/autogen
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/autogen
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/autogen
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-smolagents-integration:
needs: [detect-changes]
if: >-
@@ -2939,6 +3030,45 @@ jobs:
working-directory: ./hindsight-integrations/vapi
run: uv run pytest tests -v
test-superagent-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-superagent == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build superagent integration
working-directory: ./hindsight-integrations/superagent
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/superagent
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/superagent
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs live Hindsight + provider keys and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-litellm-integration:
needs: [detect-changes]
if: >-
@@ -2974,7 +3104,9 @@ jobs:
- name: Run tests
working-directory: ./hindsight-integrations/litellm
run: uv run pytest tests -v
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs live Hindsight + provider keys and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-pydantic-ai-integration:
needs: [detect-changes]
@@ -3013,6 +3145,45 @@ jobs:
working-directory: ./hindsight-integrations/pydantic-ai
run: uv run pytest tests -v
test-langgraph-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-langgraph == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build langgraph integration
working-directory: ./hindsight-integrations/langgraph
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/langgraph
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/langgraph
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-llamaindex-integration:
needs: [detect-changes]
if: >-
@@ -3048,7 +3219,9 @@ jobs:
- name: Run tests
working-directory: ./hindsight-integrations/llamaindex
run: uv run pytest tests -v
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-openai-agents-integration:
needs: [detect-changes]
@@ -3085,7 +3258,47 @@ jobs:
- name: Run tests
working-directory: ./hindsight-integrations/openai-agents
run: uv run pytest tests -v
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-claude-agent-sdk-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-claude-agent-sdk == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build claude-agent-sdk integration
working-directory: ./hindsight-integrations/claude-agent-sdk
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/claude-agent-sdk
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/claude-agent-sdk
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-agentcore-integration:
needs: [detect-changes]
@@ -3960,6 +4173,7 @@ jobs:
- test-pipecat-integration
- test-gemini-spark-integration
- test-vapi-integration
- test-google-adk-integration
- test-roo-code-integration
- build-control-plane
- build-docs
@@ -3980,19 +4194,24 @@ jobs:
- test-openclaw-integration
- test-integration
- test-ag2-integration
- test-autogen-integration
- test-smolagents-integration
- test-dify-integration
- test-flowise-integration
- test-crewai-integration
- test-langgraph-integration
- test-superagent-integration
- test-litellm-integration
- test-pydantic-ai-integration
- test-llamaindex-integration
- test-openai-agents-integration
- test-agentcore-integration
- test-pip-slim
- test-embed
- test-embed-windows
- test-hindsight-all
- test-hindsight-agent-sdk
- test-claude-agent-sdk-integration
- test-doc-examples
- test-upgrade
- verify-generated-files
+24
View File
@@ -220,6 +220,30 @@ migration file dispatches through `run_for_dialect`, which calls either
**MANDATORY: Run `/code-review` before pushing code or creating a pull request.** Do not push or create a PR until all "must fix" issues are resolved.
### Testing
Most tests are deterministic (MockLLM, pure functions) — assert directly.
**Tests that verify LLM behaviour use a real LLM + an LLM-as-judge.** When the thing under test is *how the model interprets a prompt* (classification, attribution, dimension preservation, instruction-following), MockLLM can't simulate it and exact string/enum asserts flake across providers and runs. Use this pattern instead:
1. Mark the test module `pytestmark = pytest.mark.hs_llm_core` (single-provider; CI runs it in the core-LLM job). Use `hs_llm_mat` only for provider-matrix acceptance tests.
2. Call the real pipeline (`LLMConfig.from_env()`, `_get_raw_config()`), e.g. `extract_facts_from_text(...)`.
3. Assert with the judge, not string matching:
```python
from tests.llm_judge import assert_meets_criteria
facts_summary = "\n".join(f"- [{f.fact_type}] {f.fact}" for f in facts)
await assert_meets_criteria(
response=facts_summary,
criteria="The first-person user statements are classified 'world' and attributed to the user, not the agent.",
context="What the input said and who was speaking.",
)
```
Rules of thumb:
- **Judge anything non-deterministic** — including `fact_type` classification and speaker attribution. Do NOT hard-assert `fact_type == "..."`; pass a `[fact_type] fact` summary to the judge instead. Structural facts that ARE deterministic (counts, presence of a field, that a substring was injected into a prompt) stay as direct asserts in fast unit tests.
- **Split the test surface**: cover the deterministic mechanics (prompt assembly, suppression logic) with fast non-LLM unit tests, and the model-following behaviour with one `hs_llm_core` judge test. (Example pair: `test_narrator_resolution.py` + `test_narrator_context_override.py`.)
- The judge model is independent of the test provider (defaults to Gemini); never judge with the same call you're testing.
### Memory Banks
- Each bank is an isolated memory store (like a "brain" for one user/agent)
- Banks have dispositions (skepticism, literalism, empathy traits 1-5) affecting reflect
+25 -2
View File
@@ -9,13 +9,36 @@ Thanks for your interest in contributing to Hindsight!
git clone [email protected]:vectorize-io/hindsight.git
cd hindsight
```
2. Set up your environment:
2. Bootstrap your dev environment in one shot:
```bash
./scripts/dev/setup.sh
```
This is idempotent (safe to re-run) and gets you ready to develop, including
offline. It:
- installs the required toolchains if missing (uv/Python, Node/npm, Rust/cargo),
- creates `.env` from `.env.example` (remember to add your LLM API key),
- configures git hooks,
- installs all Python and Node workspace dependencies,
- pre-downloads the local ML models + tokenizer so the API runs offline,
- builds the TypeScript SDK and the Rust CLI.
Useful flags: `--skip-build` (deps only), `--skip-models` (skip ML model
download), `--with-docs` (also build the docs site), `--force` (rebuild
artifacts). Docker image builds are out of scope. Run
`./scripts/dev/setup.sh --help` for details.
### Manual setup
If you'd rather set things up by hand instead of running the script above:
1. Set up your environment:
```bash
cp .env.example .env
```
Edit the .env to add LLM API key and config as required
3. Install dependencies:
2. Install dependencies:
```bash
# Python dependencies
uv sync --directory hindsight-api/
+2 -2
View File
@@ -62,9 +62,9 @@ If you need more control over how and when your agent stores and recalls memorie
```bash
export OPENAI_API_KEY=sk-xxx
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
docker run -it --pull always --name hindsight --restart unless-stopped -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
-v hindsight-data:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
+2
View File
@@ -50,6 +50,8 @@ WORKDIR /app/api
# Sync dependencies using appropriate extras based on INCLUDE_LOCAL_MODELS
# local-ml: torch, sentence-transformers, transformers, einops, flashrank, mlx (optional)
# embedded-db: pg0-embedded (always included for embedded PostgreSQL support)
# ONNX Runtime embeddings are intentionally not bundled into the official
# standalone image; install the local-onnx extra in custom images when needed.
RUN if [ "$INCLUDE_LOCAL_MODELS" = "true" ]; then \
uv sync --extra local-ml --extra embedded-db; \
else \
+45
View File
@@ -43,11 +43,56 @@ check_pg0_data_integrity() {
return 0
}
# =============================================================================
# Embedded pg0 writability pre-check (#1483)
#
# The container runs as the unprivileged `hindsight` user (UID 1000). When the
# pg0 data directory is a host bind mount (e.g. `-v $HOME/dir:/home/hindsight/.pg0`)
# that is not owned by UID 1000 — the default on macOS Docker Desktop and most
# non-1000 Linux hosts — pg0 fails with the opaque "Permission denied (os error
# 13)". We cannot chown it ourselves without root (and the image is deliberately
# rootless), so we surface an actionable message up front instead.
#
# Docker *named* volumes are seeded with the image directory's ownership (UID
# 1000) on first use, so they avoid this entirely — hence the named-volume
# recommendation below and in the README.
# =============================================================================
check_pg0_writable() {
local pg0_data_dir="$1"
# Only relevant for embedded pg0; an external database doesn't use this dir.
if [ -n "${HINDSIGHT_API_DATABASE_URL:-}" ]; then
return 0
fi
mkdir -p "$pg0_data_dir" 2>/dev/null || true
if touch "$pg0_data_dir/.hindsight-write-test" 2>/dev/null; then
rm -f "$pg0_data_dir/.hindsight-write-test" 2>/dev/null || true
return 0
fi
echo "❌ The embedded database directory $pg0_data_dir is not writable by this container (UID $(id -u))."
echo ""
echo " A host directory was bind-mounted but is not owned by the container user (UID 1000)."
echo " Hindsight runs rootless and cannot fix this for you. Choose one:"
echo ""
echo " • Recommended — use a Docker named volume (auto-owned by the container):"
echo " -v hindsight-data:/home/hindsight/.pg0"
echo ""
echo " • Or keep the host path and run as your host user, chowning it to match:"
echo " sudo chown -R \$(id -u):\$(id -g) <host-directory>"
echo " docker run --user \$(id -u):\$(id -g) -e HOME=/home/hindsight ..."
echo ""
echo " See https://github.com/vectorize-io/hindsight/issues/1483"
return 1
}
if [ "${HINDSIGHT_START_ALL_SOURCE_ONLY:-false}" = "true" ]; then
return 0 2>/dev/null || exit 0
fi
check_pg0_data_integrity "${HOME}/.pg0"
check_pg0_writable "${HOME}/.pg0" || exit 1
# Service flags (default to true if not set)
ENABLE_API="${HINDSIGHT_ENABLE_API:-true}"
+49 -1
View File
@@ -8,7 +8,7 @@ source "$SCRIPT_DIR/start-all.sh"
unset HINDSIGHT_START_ALL_SOURCE_ONLY
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
trap 'chmod -R u+rwx "$TMP_DIR" 2>/dev/null || true; rm -rf "$TMP_DIR"' EXIT
assert_contains() {
local output="$1"
@@ -71,3 +71,51 @@ nonempty_output="$(check_pg0_data_integrity "$TMP_DIR/nonempty")"
assert_contains "$nonempty_output" "WARNING: pg0 data directory exists"
echo "start-all pg0 integrity checks passed"
# =============================================================================
# check_pg0_writable (#1483)
# These rely on filesystem permissions, which root bypasses; skip under root.
# =============================================================================
if [ "$(id -u)" != "0" ]; then
# Writable directory: returns 0, prints nothing, leaves no artifact behind.
mkdir -p "$TMP_DIR/writable"
writable_output="$(check_pg0_writable "$TMP_DIR/writable")"
assert_empty "$writable_output"
if [ -e "$TMP_DIR/writable/.hindsight-write-test" ]; then
echo "check_pg0_writable left its write-test file behind"
exit 1
fi
# Non-writable directory: returns 1 with actionable guidance.
mkdir -p "$TMP_DIR/readonly"
chmod 000 "$TMP_DIR/readonly"
set +e
readonly_output="$(check_pg0_writable "$TMP_DIR/readonly" 2>&1)"
readonly_rc=$?
set -e
chmod 755 "$TMP_DIR/readonly"
if [ "$readonly_rc" -eq 0 ]; then
echo "check_pg0_writable should fail on a non-writable directory"
exit 1
fi
assert_contains "$readonly_output" "not writable"
assert_contains "$readonly_output" "hindsight-data:/home/hindsight/.pg0"
assert_contains "$readonly_output" "--user"
# External database configured: skip the check regardless of dir perms.
mkdir -p "$TMP_DIR/extdb"
chmod 000 "$TMP_DIR/extdb"
set +e
HINDSIGHT_API_DATABASE_URL="postgres://x" check_pg0_writable "$TMP_DIR/extdb" >/dev/null 2>&1
extdb_rc=$?
set -e
chmod 755 "$TMP_DIR/extdb"
if [ "$extdb_rc" -ne 0 ]; then
echo "check_pg0_writable should skip when an external database is configured"
exit 1
fi
echo "start-all pg0 writability checks passed"
else
echo "⚠️ Running as root; skipping pg0 writability checks (permissions are bypassed)."
fi
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.7.1
appVersion: "0.7.1"
version: 0.7.2
appVersion: "0.7.2"
keywords:
- ai
- memory
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-all",
"version": "0.7.1",
"version": "0.7.2",
"description": "Node.js programmatic lifecycle manager for Hindsight — embeds a local hindsight daemon in a Node application. Pair with @vectorize-io/hindsight-client for memory operations.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-all-slim"
version = "0.7.1"
version = "0.7.2"
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim==0.7.1",
"hindsight-api-slim==0.7.2",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
]
+3 -3
View File
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-all"
version = "0.7.1"
version = "0.7.2"
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim[all]==0.7.1",
"hindsight-api-slim[all]==0.7.2",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
]
@@ -21,7 +21,7 @@ hindsight-embed = { workspace = true }
[project.optional-dependencies]
local-llm = [
"hindsight-api-slim[local-llm]==0.7.1",
"hindsight-api-slim[local-llm]==0.7.2",
]
test = [
"pytest>=7.0.0",
+1 -1
View File
@@ -99,7 +99,7 @@ hindsight-api
## Docker
```bash
docker run --rm -it -p 8888:8888 \
docker run -it --name hindsight --restart unless-stopped -p 8888:8888 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
+1 -1
View File
@@ -53,4 +53,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.7.1"
__version__ = "0.7.2"
@@ -17,7 +17,9 @@ import asyncpg
import typer
from ..config import DEFAULT_DATABASE_SCHEMA, HindsightConfig
from ..engine.memory_engine import _current_schema
from ..engine.schema import fq_table_explicit as _fq_table
from ..engine.transfer import export_bank
from ..extensions import TenantExtension, load_extension
from ..pg0 import parse_pg0_url, resolve_database_url
@@ -50,18 +52,38 @@ BACKUP_TABLES = [
"unit_entities",
"entity_cooccurrences",
"memory_links",
"observation_history",
"mental_models",
"mental_model_history",
"directives",
"async_operations",
"webhooks",
"file_storage",
"audit_log",
"llm_requests",
"graph_maintenance_queue",
]
MANIFEST_VERSION = "1"
async def _admin_connect(db_url: str) -> asyncpg.Connection:
"""Open a raw asyncpg connection to an admin DB URL.
``resolve_database_url`` handles both plain ``postgres://`` (passthrough) and
``pg0://`` (boots the embedded server and returns its real libpq URL), so this
is the only step needed to connect. JSON codecs are registered so ``jsonb``
columns decode to Python objects (used by the export row dumps).
"""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
conn = await asyncpg.connect(await resolve_database_url(db_url))
for type_name in ("json", "jsonb"):
await conn.set_type_codec(type_name, encoder=json.dumps, decoder=json.loads, schema="pg_catalog")
return conn
async def _backup(database_url: str, output_path: Path, schema: str = "public") -> dict[str, Any]:
"""Backup all tables to a zip file using binary COPY protocol."""
conn = await asyncpg.connect(database_url)
@@ -330,6 +352,123 @@ def run_db_migration(
typer.echo(f"Database migrations completed successfully for {len(schemas)} schema(s)")
async def _run_export_bank(db_url: str, bank_id: str, output: Path, schema: str, include_history: bool) -> int:
"""Export a whole bank to a ZIP archive."""
conn = await _admin_connect(db_url)
try:
# export_bank resolves table names via fq_table (the _current_schema
# contextvar); set it so the raw connection targets the right schema.
_current_schema.set(schema)
data = await export_bank(conn, bank_id, include_history=include_history)
finally:
await conn.close()
output.write_bytes(data)
return len(data)
@app.command(name="export-bank")
def export_bank_command(
bank_id: str = typer.Option(..., "--bank", "-b", help="Bank id to export."),
output: Path = typer.Option(..., "--output", "-o", help="Path to write the .zip archive."),
schema: str | None = typer.Option(
None,
"--schema",
"-s",
help="Database schema the bank lives in. Defaults to the configured base schema.",
),
include_history: bool = typer.Option(
False,
"--include-history",
help="Also export operational history (audit_log, llm_requests). Off by default.",
),
):
"""Export an entire bank to a portable ZIP (no embeddings — regenerated on import).
Carries documents, facts, observations, bank config, mental models, directives
and webhooks so the bank can be imported into a new instance configured with a
different embedding model / vector / text-search backend.
"""
config = HindsightConfig.from_env()
if not config.database_url:
typer.echo("Error: Database URL not configured.", err=True)
typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True)
raise typer.Exit(1)
target_schema = schema or config.database_schema or DEFAULT_DATABASE_SCHEMA
typer.echo(f"Exporting bank '{bank_id}' from schema '{target_schema}'...")
size = asyncio.run(_run_export_bank(config.database_url, bank_id, output, target_schema, include_history))
typer.echo(f"Exported bank '{bank_id}' to {output} ({size} bytes)")
async def _run_import_bank(archive_path: Path, schema: str, target_bank_id: str | None, include_history: bool):
"""Boot a MemoryEngine (for the target's embedding model) and restore a bank archive."""
# MemoryEngine is heavy (loads embeddings); import it lazily so other admin
# commands don't pay for it. _current_schema is imported at module top.
from ..engine.memory_engine import MemoryEngine
from ..models import RequestContext
archive_bytes = archive_path.read_bytes()
# run_migrations=True so a fresh target instance is provisioned at this
# instance's embedding dimension / vector / text-search backend before restore.
engine = MemoryEngine(run_migrations=True)
await engine.initialize()
try:
_current_schema.set(schema)
context = RequestContext(internal=True, user_initiated=True)
return await engine.import_bank_async(
archive_bytes,
context,
target_bank_id=target_bank_id,
include_history=include_history,
)
finally:
await engine.close()
@app.command(name="import-bank")
def import_bank_command(
archive: Path = typer.Option(..., "--archive", "-a", help="Path to the .zip produced by export-bank."),
schema: str | None = typer.Option(
None, "--schema", "-s", help="Target schema. Defaults to the configured base schema."
),
target_bank: str | None = typer.Option(
None, "--target-bank", help="Override the bank id (defaults to the archive's source bank)."
),
include_history: bool = typer.Option(
False, "--include-history", help="Also restore operational history if present in the archive."
),
):
"""Restore a whole bank from an export-bank archive into THIS instance.
Re-embeds facts with this instance's configured embedding model and rebuilds
links and indexes — the import half of a cross-instance migration. Run against
an instance configured with the desired embedding / vector / text-search backend.
The target bank must not already exist (import restores a whole bank, not a merge).
"""
config = HindsightConfig.from_env()
if not config.database_url:
typer.echo("Error: Database URL not configured.", err=True)
typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True)
raise typer.Exit(1)
target_schema = schema or config.database_schema or DEFAULT_DATABASE_SCHEMA
typer.echo(f"Importing bank archive '{archive}' into schema '{target_schema}'...")
result = asyncio.run(_run_import_bank(archive, target_schema, target_bank, include_history))
typer.echo(
f"Imported bank '{result.bank_id}': {result.documents_imported} doc(s), "
f"{result.facts_imported} fact(s), {result.observations_imported} observation(s), "
f"{result.mental_models_imported} mental model(s), "
f"{result.mental_model_history_imported} mm-history row(s), {result.directives_imported} directive(s), "
f"{result.webhooks_imported} webhook(s), {result.history_rows_imported} history row(s)"
)
async def _decommission_worker(db_url: str, worker_id: str, schema: str = "public") -> int:
"""Release all tasks owned by a worker, setting them back to pending status."""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
@@ -0,0 +1,253 @@
"""Move mental-model and observation history into dedicated tables.
Both histories were accumulated in a single JSONB/CLOB ``history`` column
(``mental_models.history`` and ``memory_units.history``), appended to on every
update. That design has two problems:
1. **Unbounded growth on observations.** The observation write path appended a
snapshot on every update with no cap at all, so a frequently-reinforced
observation grew its ``history`` array until it crossed Postgres's hard 256MB
jsonb limit (SQLSTATE 54000), after which every further UPDATE failed and the
row was stuck.
2. **Wrong-axis cap on mental models.** The mental-model cap bounded the *number*
of entries (50), not their *size* — a single large reflect snapshot could
still blow the budget — and rewrote the whole array (plus TOAST) on every
refresh, defeating HOT updates.
This migration creates one row per history entry in two dedicated tables, with
an index that makes "most recent N for this item" cheap, then drops the old
columns. The cap is now enforced at write time as a bounded DELETE of the
oldest over-cap rows (see config ``*_HISTORY_MAX_ENTRIES``).
Revision ID: a7b8c9d0e1f2
Revises: d3e4f5a6b7c8
Create Date: 2026-06-05
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a7b8c9d0e1f2"
down_revision: str | Sequence[str] | None = "d3e4f5a6b7c8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
# ---------------------------------------------------------------------------
# PostgreSQL
# ---------------------------------------------------------------------------
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
# Both tables share the same shape: surrogate id, FK to the parent, bank_id,
# the snapshot payload as a single JSONB ``content`` blob, and changed_at.
# The payload is per-row (one change per row) so it stays small — this is NOT
# the old single-column-grows-forever design; growth is bounded by row count
# plus the write-time cap. Folding the previous_* fields into one JSONB keeps
# the schema dialect-simple (no array columns) and flexible.
# --- mental_model_history -------------------------------------------------
# content: {"previous_content": ..., "previous_reflect_response": {...}}
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}mental_model_history (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
mental_model_id VARCHAR(64) NOT NULL,
bank_id VARCHAR(64) NOT NULL,
content JSONB NOT NULL,
changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
FOREIGN KEY (mental_model_id, bank_id)
REFERENCES {schema}mental_models(id, bank_id) ON DELETE CASCADE
)
"""
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_mm_history_model "
f"ON {schema}mental_model_history (bank_id, mental_model_id, changed_at DESC, id DESC)"
)
# --- observation_history --------------------------------------------------
# content: {"previous_text", "previous_tags", "previous_occurred_start",
# "previous_occurred_end", "previous_mentioned_at", "new_source_memory_ids"}
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}observation_history (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
observation_id UUID NOT NULL,
bank_id VARCHAR(64) NOT NULL,
content JSONB NOT NULL,
changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
FOREIGN KEY (observation_id)
REFERENCES {schema}memory_units(id) ON DELETE CASCADE
)
"""
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_observation_history_obs "
f"ON {schema}observation_history (observation_id, changed_at DESC, id DESC)"
)
# --- backfill mental models ----------------------------------------------
# Explode each row's history array into rows, preserving chronological order
# via WITH ORDINALITY so the IDENTITY id tie-breaks oldest->newest correctly.
# changed_at is promoted to its own column; the rest of the element becomes
# ``content`` (the ``- 'changed_at'`` strips the now-redundant key).
op.execute(
f"""
INSERT INTO {schema}mental_model_history (mental_model_id, bank_id, content, changed_at)
SELECT mm.id, mm.bank_id,
e - 'changed_at',
COALESCE(NULLIF(e->>'changed_at', '')::timestamptz, now())
FROM {schema}mental_models mm
CROSS JOIN LATERAL jsonb_array_elements(mm.history) WITH ORDINALITY a(e, ord)
WHERE mm.history IS NOT NULL
AND jsonb_typeof(mm.history) = 'array'
AND jsonb_array_length(mm.history) > 0
ORDER BY mm.id, mm.bank_id, ord
"""
)
# --- backfill observations -----------------------------------------------
op.execute(
f"""
INSERT INTO {schema}observation_history (observation_id, bank_id, content, changed_at)
SELECT mu.id, mu.bank_id,
e - 'changed_at',
COALESCE(NULLIF(e->>'changed_at', '')::timestamptz, now())
FROM {schema}memory_units mu
CROSS JOIN LATERAL jsonb_array_elements(mu.history) WITH ORDINALITY a(e, ord)
WHERE mu.fact_type = 'observation'
AND mu.history IS NOT NULL
AND jsonb_typeof(mu.history) = 'array'
AND jsonb_array_length(mu.history) > 0
ORDER BY mu.id, ord
"""
)
# --- drop the legacy columns ---------------------------------------------
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS history")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS history")
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
# Re-add the columns (empty — historical content is not reconstructed back
# into the array form; the dedicated tables are dropped below).
op.execute(f"ALTER TABLE {schema}mental_models ADD COLUMN IF NOT EXISTS history JSONB DEFAULT '[]'::jsonb")
op.execute(f"ALTER TABLE {schema}memory_units ADD COLUMN IF NOT EXISTS history JSONB DEFAULT '[]'::jsonb")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_observation_history_obs")
op.execute(f"DROP TABLE IF EXISTS {schema}observation_history")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_mm_history_model")
op.execute(f"DROP TABLE IF EXISTS {schema}mental_model_history")
# ---------------------------------------------------------------------------
# Oracle 23ai
# ---------------------------------------------------------------------------
def _oracle_upgrade() -> None:
# Same single-JSONB shape as PG: ``content`` holds the snapshot payload as a
# CLOB IS JSON. The legacy per-element JSON object (minus changed_at, promoted
# to its own column) is carried through verbatim on backfill — the array
# columns the previous design needed are gone.
op.execute(
"""
CREATE TABLE IF NOT EXISTS mental_model_history (
id NUMBER GENERATED BY DEFAULT AS IDENTITY,
mental_model_id VARCHAR2(256) NOT NULL,
bank_id VARCHAR2(256) NOT NULL,
content CLOB NOT NULL
CONSTRAINT mmh_content_json CHECK (content IS JSON),
changed_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT pk_mental_model_history PRIMARY KEY (id),
CONSTRAINT fk_mmh_model FOREIGN KEY (mental_model_id, bank_id)
REFERENCES mental_models(id, bank_id) ON DELETE CASCADE
)
"""
)
op.execute(
"CREATE INDEX idx_mm_history_model ON mental_model_history (bank_id, mental_model_id, changed_at DESC, id DESC)"
)
op.execute(
"""
CREATE TABLE IF NOT EXISTS observation_history (
id NUMBER GENERATED BY DEFAULT AS IDENTITY,
observation_id RAW(16) NOT NULL,
bank_id VARCHAR2(256) NOT NULL,
content CLOB NOT NULL
CONSTRAINT oh_content_json CHECK (content IS JSON),
changed_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT pk_observation_history PRIMARY KEY (id),
CONSTRAINT fk_oh_obs FOREIGN KEY (observation_id)
REFERENCES memory_units(id) ON DELETE CASCADE
)
"""
)
op.execute(
"CREATE INDEX idx_observation_history_obs ON observation_history (observation_id, changed_at DESC, id DESC)"
)
bind = op.get_bind()
# Backfill via JSON_TABLE. ``content`` is the whole element (FORMAT JSON PATH
# '$'); changed_at is also promoted to its own column. Backfilled content may
# therefore still carry a redundant changed_at key, which the read path
# ignores in favour of the column — harmless, and avoids JSON surgery here.
bind.exec_driver_sql(
"""
INSERT INTO mental_model_history (mental_model_id, bank_id, content, changed_at)
SELECT mm.id, mm.bank_id, jt.content, NVL(jt.changed_at, SYSTIMESTAMP)
FROM mental_models mm,
JSON_TABLE(mm.history, '$[*]' COLUMNS (
seq FOR ORDINALITY,
content CLOB FORMAT JSON PATH '$',
changed_at TIMESTAMP WITH TIME ZONE PATH '$.changed_at'
)) jt
WHERE mm.history IS NOT NULL
ORDER BY mm.id, mm.bank_id, jt.seq
"""
)
bind.exec_driver_sql(
"""
INSERT INTO observation_history (observation_id, bank_id, content, changed_at)
SELECT mu.id, mu.bank_id, jt.content, NVL(jt.changed_at, SYSTIMESTAMP)
FROM memory_units mu,
JSON_TABLE(mu.history, '$[*]' COLUMNS (
seq FOR ORDINALITY,
content CLOB FORMAT JSON PATH '$',
changed_at TIMESTAMP WITH TIME ZONE PATH '$.changed_at'
)) jt
WHERE mu.fact_type = 'observation' AND mu.history IS NOT NULL
ORDER BY mu.id, jt.seq
"""
)
op.execute("ALTER TABLE mental_models DROP COLUMN history")
op.execute("ALTER TABLE memory_units DROP COLUMN history")
def _oracle_downgrade() -> None:
op.execute("ALTER TABLE mental_models ADD history CLOB DEFAULT '[]' NOT NULL")
op.execute("ALTER TABLE memory_units ADD history CLOB DEFAULT '[]'")
op.execute("DROP TABLE observation_history CASCADE CONSTRAINTS")
op.execute("DROP TABLE mental_model_history CASCADE CONSTRAINTS")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -0,0 +1,96 @@
"""Add llm_requests table for per-bank LLM request tracing.
Stores one row per logical LLM call Hindsight makes (success and failure),
capturing the input messages, model output, token usage (input/output/cached/
total), finish reason, and caller metadata. Disabled by default at the
application layer (HINDSIGHT_API_LLM_TRACE_ENABLED); this migration only
creates the table.
PostgreSQL only — the tracing subsystem is not wired for Oracle, so the Oracle
slot is intentionally absent (mirrors the audit_log table).
Revision ID: d3e4f5a6b7c8
Revises: c1d2e3f4a5b6
Create Date: 2026-06-01
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "d3e4f5a6b7c8"
down_revision: str | Sequence[str] | None = "c1d2e3f4a5b6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}llm_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bank_id TEXT,
operation TEXT,
scope TEXT,
-- OTel-style grouping: trace_id is shared by every LLM call of one
-- operation invocation (e.g. all calls of a single reflect run);
-- parent_span_id is that operation span; span_id is this call.
trace_id TEXT,
span_id TEXT,
parent_span_id TEXT,
provider TEXT,
model TEXT,
status TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
duration_ms INTEGER,
input_tokens INTEGER,
output_tokens INTEGER,
cached_tokens INTEGER,
total_tokens INTEGER,
input JSONB,
output JSONB,
error TEXT,
llm_info JSONB DEFAULT '{{}}'::jsonb,
metadata JSONB DEFAULT '{{}}'::jsonb
)
"""
)
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_llm_requests_bank_started ON {schema}llm_requests (bank_id, started_at DESC)"
)
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_llm_requests_status_started ON {schema}llm_requests (status, started_at DESC)"
)
op.execute(f"CREATE INDEX IF NOT EXISTS idx_llm_requests_started ON {schema}llm_requests (started_at DESC)")
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_llm_requests_trace ON {schema}llm_requests (bank_id, trace_id, started_at)"
)
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_llm_requests_started")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_llm_requests_status_started")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_llm_requests_bank_started")
op.execute(f"DROP TABLE IF EXISTS {schema}llm_requests")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,153 @@
"""Add server-side routines for background maintenance sweeps.
Installs two PL/pgSQL discovery routines in the ``public`` schema. Both loop
over every schema that actually holds the relevant table (via ``pg_class``), so
a single function call covers all tenants in one round-trip instead of the
per-tenant query storm that a client-side loop would create at thousands of
tenants.
- ``public.banks_needing_consolidation()`` -> (schema_name, bank_id) for banks
that have eligible-but-unscheduled facts (``consolidated_at IS NULL AND
consolidation_failed_at IS NULL`` for consolidatable fact types), have
auto-consolidation not explicitly disabled at the bank level, and have no
consolidation operation already pending/processing. Drives the periodic
reconcile that re-schedules consolidation after a terminal failure left facts
stranded (see HINDSIGHT_API_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS).
- ``public.schemas_with_expired_rows(p_table, p_ts_col, p_days)`` -> schema
names that hold at least one ``p_table`` row older than ``p_days``. Drives the
cross-tenant retention sweeps for ``audit_log`` and ``llm_requests``; the loop
then issues a DELETE only against the returned schemas.
These are read-only (STABLE) discovery routines — the caller performs the
enqueue/DELETE — so installing them never mutates data.
PostgreSQL only — the worker poller and these tables are not wired for Oracle,
so the Oracle slot is intentionally absent (mirrors the audit_log / llm_requests
table migrations). The routines live in ``public`` and are CREATE OR REPLACE, so
running this migration once per tenant schema is idempotent.
Revision ID: e5f6a7b8c9d0
Revises: a7b8c9d0e1f2
Create Date: 2026-06-05
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "e5f6a7b8c9d0"
down_revision: str | Sequence[str] | None = "a7b8c9d0e1f2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _is_base_schema_run() -> bool:
"""True only for the base-schema migration (no per-tenant target_schema).
These routines live in the shared ``public`` schema, so they must be created
exactly once. Running ``CREATE OR REPLACE FUNCTION public....`` again from each
concurrent per-tenant migration aborts with ``tuple concurrently updated`` on
the ``pg_proc`` catalog row, so tenant runs skip it (the base run already
created the function for every tenant to use).
"""
return not context.config.get_main_option("target_schema")
def _pg_upgrade() -> None:
if not _is_base_schema_run():
return
# Banks with eligible-but-unscheduled facts and no in-flight consolidation.
# Auto-consolidation is filtered here only at the bank level (cheap prune);
# the full hierarchical resolution (global -> tenant -> bank, plus
# enable_observations) is done by the caller for the small returned set.
op.execute(
"""
CREATE OR REPLACE FUNCTION public.banks_needing_consolidation()
RETURNS TABLE(schema_name text, bank_id text)
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
BEGIN
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'memory_units' AND c.relkind = 'r'
LOOP
RETURN QUERY EXECUTE format($q$
SELECT %1$L::text, m.bank_id
FROM %1$I.memory_units m
JOIN %1$I.banks b ON b.bank_id = m.bank_id
WHERE m.consolidated_at IS NULL
AND m.consolidation_failed_at IS NULL
AND m.fact_type IN ('experience', 'world')
AND COALESCE(b.config -> 'enable_auto_consolidation', 'true'::jsonb) <> 'false'::jsonb
AND NOT EXISTS (
SELECT 1 FROM %1$I.async_operations o
WHERE o.bank_id = m.bank_id
AND o.operation_type = 'consolidation'
AND o.status IN ('pending', 'processing')
)
GROUP BY m.bank_id
$q$, sch);
END LOOP;
END;
$fn$;
"""
)
# Schemas holding at least one row of p_table older than p_days. p_ts_col is
# the timestamp column to compare. Returns nothing when p_days <= 0
# (retention disabled).
op.execute(
"""
CREATE OR REPLACE FUNCTION public.schemas_with_expired_rows(
p_table text, p_ts_col text, p_days int
)
RETURNS SETOF text
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
has_expired boolean;
BEGIN
IF p_days IS NULL OR p_days <= 0 THEN
RETURN;
END IF;
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = p_table AND c.relkind = 'r'
LOOP
EXECUTE format(
'SELECT EXISTS (SELECT 1 FROM %I.%I WHERE %I < NOW() - make_interval(days => $1))',
sch, p_table, p_ts_col
) INTO has_expired USING p_days;
IF has_expired THEN
RETURN NEXT sch;
END IF;
END LOOP;
END;
$fn$;
"""
)
def _pg_downgrade() -> None:
if not _is_base_schema_run():
return
op.execute("DROP FUNCTION IF EXISTS public.banks_needing_consolidation()")
op.execute("DROP FUNCTION IF EXISTS public.schemas_with_expired_rows(text, text, int)")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
+302 -220
View File
@@ -17,7 +17,13 @@ from typing import Any, Literal
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, Request, UploadFile
from fastapi.middleware.gzip import GZipMiddleware
from hindsight_api.engine.audit import AuditEntry, AuditLogger
from hindsight_api.engine.audit import (
AuditEntry,
AuditLogger,
AuditLogListResponse,
AuditLogStatsResponse,
)
from hindsight_api.engine.llm_trace import LLMRequestListResponse, LLMRequestStatsResponse
from hindsight_api.extensions import AuthenticationError
@@ -1444,6 +1450,18 @@ class ReprocessDocumentResponse(BaseModel):
items_count: int
class DocumentImportSubmitResponse(BaseModel):
"""Response for the async document-import endpoint (202).
The import runs in the background; poll the operations endpoint for status.
The imported/skipped counts (documents_imported, facts_imported,
observations_imported, etc.) are written to the operation's result_metadata.
"""
operation_id: str
status: str = "pending"
class DeleteResponse(BaseModel):
"""Response model for delete operations."""
@@ -2150,6 +2168,28 @@ async def apply_bank_template_manifest(
)
class OperationProgress(BaseModel):
"""Last-known progress snapshot for a long-running async operation.
Written at coarse phase/batch boundaries by the worker (consolidation, batch
retain). Lets an operator polling the operation status API distinguish a healthy
long-running job (``processed`` advancing across polls) from a frozen one (same
numbers, no movement in ``at``). Absent (``null``) on operations that never
reached a checkpoint completed-instantly or pre-feature rows.
"""
stage: str = Field(description="Coarse phase the operation last reported (e.g. 'processing_batch').")
at: str = Field(description="ISO-8601 timestamp when this snapshot was written.")
processed: int | None = Field(
default=None, description="Units of work finished so far (sub-batches, memories), when known."
)
total: int | None = Field(default=None, description="Total units of work for the operation, when known.")
detail: dict[str, int] | None = Field(
default=None,
description="Operation-specific counters (e.g. observations_created, round, items_in_sub_batch).",
)
class OperationResponse(BaseModel):
"""Response model for a single async operation."""
@@ -2174,6 +2214,10 @@ class OperationResponse(BaseModel):
items_count: int
document_id: str | None = None
created_at: str
updated_at: str | None = Field(
default=None,
description="When this operation's row last changed (claim, progress heartbeat, or completion).",
)
status: str
error_message: str | None
retry_count: int | None = Field(
@@ -2190,6 +2234,10 @@ class OperationResponse(BaseModel):
"some backpressure window opens. Always null for completed tasks."
),
)
progress: OperationProgress | None = Field(
default=None,
description="Last-known progress snapshot for a running operation; null if none was recorded.",
)
class ConsolidationRequest(BaseModel):
@@ -2325,6 +2373,10 @@ class OperationStatusResponse(BaseModel):
"immediate pickup."
),
)
progress: OperationProgress | None = Field(
default=None,
description="Last-known progress snapshot for a running operation; null if none was recorded.",
)
result_metadata: dict[str, Any] | None = Field(
default=None,
description="Internal metadata for debugging. Structure may change without notice. Not for production use.",
@@ -2362,6 +2414,10 @@ class FeaturesInfo(BaseModel):
worker: bool = Field(description="Whether the background worker is enabled")
bank_config_api: bool = Field(description="Whether per-bank configuration API is enabled")
file_upload_api: bool = Field(description="Whether file upload/conversion API is enabled")
document_export_api: bool = Field(description="Whether the document export endpoint is enabled")
document_import_api: bool = Field(description="Whether the document import endpoint is enabled")
audit_log: bool = Field(description="Whether audit logging is enabled")
llm_trace: bool = Field(description="Whether per-bank LLM request tracing is enabled")
class VersionResponse(BaseModel):
@@ -2377,6 +2433,8 @@ class VersionResponse(BaseModel):
"worker": True,
"bank_config_api": False,
"file_upload_api": True,
"document_export_api": True,
"document_import_api": True,
},
}
}
@@ -3023,6 +3081,10 @@ def _register_routes(app: FastAPI):
worker=config.worker_enabled,
bank_config_api=config.enable_bank_config_api,
file_upload_api=config.enable_file_upload_api,
document_export_api=config.enable_document_export_api,
document_import_api=config.enable_document_import_api,
audit_log=config.audit_log_enabled,
llm_trace=config.llm_trace_enabled,
),
)
@@ -5265,6 +5327,124 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in GET /v1/default/banks/{bank_id}/export: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
# =====================================================================
# Document Transfer (Export / Import between banks — no LLM re-extraction)
# =====================================================================
@app.get(
# Dedicated path (not under /documents/) to avoid colliding with the
# greedy GET /documents/{document_id:path} route, which would otherwise
# capture "export"/"import" as a document id.
"/v1/default/banks/{bank_id}/document-transfer",
summary="Export documents",
description="Export documents (extracted facts, entity names, causal links, chunks) from a bank as a "
"transfer ZIP archive. Embeddings and database ids are not included — importing re-embeds with the target "
"bank's model and re-resolves entities. Consolidated observations are excluded unless include_observations=true. "
"Pass document_id query params to export specific documents, or omit to export the whole bank.",
operation_id="export_documents",
tags=["Document Transfer"],
responses={200: {"content": {"application/zip": {}}, "description": "Transfer archive"}},
)
async def api_export_documents(
bank_id: str,
document_id: list[str] | None = Query(default=None, description="Document id(s) to export; omit for all"),
include_observations: bool = Query(
default=False, description="Also export consolidated observations (restored on import)"
),
request_context: RequestContext = Depends(get_request_context),
):
"""Export documents from a bank into a transfer ZIP archive."""
from fastapi.responses import Response
try:
if not get_config().enable_document_export_api:
raise HTTPException(
status_code=404,
detail="Document export API is disabled. "
"Set HINDSIGHT_API_ENABLE_DOCUMENT_EXPORT_API=true to enable.",
)
profile = await app.state.memory.get_bank_profile(
bank_id, request_context=request_context, create_if_missing=False
)
if profile is None:
raise HTTPException(status_code=404, detail=f"Bank '{bank_id}' not found")
try:
archive = await app.state.memory.export_documents_async(
bank_id,
request_context,
list(document_id) if document_id else None,
include_observations=include_observations,
)
except ValueError as e:
# e.g. include_observations combined with a document_id subset.
raise HTTPException(status_code=400, detail=str(e))
return Response(
content=archive,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{bank_id}-documents.zip"'},
)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
logger.error(f"Error in GET /v1/default/banks/{bank_id}/document-transfer: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/document-transfer",
response_model=DocumentImportSubmitResponse,
status_code=202,
summary="Import documents (async)",
description="Submit a transfer archive (produced by the export endpoint) for import into a bank. Runs as a "
"background operation: facts are re-embedded with the target bank's embedding model and entities are "
"re-resolved — no LLM extraction. Returns an operation_id; poll "
"GET /v1/default/banks/{bank_id}/operations/{operation_id} for status and the imported/skipped counts in "
"result_metadata. Use on_conflict to control existing document ids: skip (default), replace, or new-id.",
operation_id="import_documents",
tags=["Document Transfer"],
)
@audited("import_documents", request_param=None)
async def api_import_documents(
bank_id: str,
file: UploadFile = File(..., description="Transfer ZIP archive"),
on_conflict: str = Query(default="skip", description="skip | replace | new-id"),
request_context: RequestContext = Depends(get_request_context),
):
"""Submit a transfer archive for async import into a bank."""
try:
if not get_config().enable_document_import_api:
raise HTTPException(
status_code=404,
detail="Document import API is disabled. "
"Set HINDSIGHT_API_ENABLE_DOCUMENT_IMPORT_API=true to enable.",
)
if on_conflict not in ("skip", "replace", "new-id"):
raise HTTPException(
status_code=400, detail=f"Invalid on_conflict '{on_conflict}' (expected skip|replace|new-id)"
)
archive_bytes = await file.read()
try:
submission = await app.state.memory.import_documents_async(
bank_id, archive_bytes, request_context, on_conflict
)
except ValueError as e:
# Invalid archive / unsupported schema version — fail fast.
raise HTTPException(status_code=400, detail=str(e))
return DocumentImportSubmitResponse(operation_id=submission["operation_id"])
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
logger.error(f"Error in POST /v1/default/banks/{bank_id}/document-transfer: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/bank-template-schema",
summary="Get bank template JSON Schema",
@@ -6202,48 +6382,13 @@ def _register_routes(app: FastAPI):
raise HTTPException(status_code=500, detail=str(e))
# ---- Audit Logs ----
# Response models live in engine/audit.py so the MemoryEngine read methods
# (list_audit_logs / audit_log_stats) can build and return them directly.
class AuditLogEntry(BaseModel):
"""A single audit log entry."""
id: str
action: str
transport: str
bank_id: str | None
started_at: str | None
ended_at: str | None
duration_ms: int | None = Field(
default=None,
description="Server-computed duration in milliseconds (started_at → ended_at). Null if not yet completed.",
)
request: dict[str, Any] | None
response: dict[str, Any] | None
metadata: dict[str, Any]
class AuditLogListResponse(BaseModel):
"""Response model for list audit logs endpoint."""
bank_id: str
total: int
limit: int
offset: int
items: list[AuditLogEntry]
class AuditLogStatsBucket(BaseModel):
"""A single time bucket in audit log stats."""
time: str
actions: dict[str, int]
total: int
class AuditLogStatsResponse(BaseModel):
"""Response model for audit log stats endpoint."""
bank_id: str
period: str
trunc: str
start: str
buckets: list[AuditLogStatsBucket]
# ---- LLM Request Traces ----
# Response models + queries live in the engine (engine/llm_trace.py and
# MemoryEngine.list_llm_requests / llm_request_stats). The handlers below
# only parse params, delegate to the engine, and map a missing bank to 404.
@app.get(
"/v1/default/banks/{bank_id}/audit-logs",
@@ -6265,120 +6410,19 @@ def _register_routes(app: FastAPI):
):
"""List audit log entries for a bank."""
try:
from hindsight_api.engine.memory_engine import fq_table
pool = await app.state.memory._get_backend()
# Read endpoint: verify bank exists without auto-creating it.
if (
await app.state.memory.get_bank_profile(
bank_id, request_context=request_context, create_if_missing=False
)
is None
):
result = await app.state.memory.list_audit_logs(
bank_id,
request_context=request_context,
action=action,
transport=transport,
start_date=datetime.fromisoformat(start_date.replace("Z", "+00:00")) if start_date else None,
end_date=datetime.fromisoformat(end_date.replace("Z", "+00:00")) if end_date else None,
limit=limit,
offset=offset,
)
if result is None:
raise HTTPException(status_code=404, detail=f"Bank '{bank_id}' not found")
from hindsight_api.engine.db_utils import acquire_with_retry
async with acquire_with_retry(pool) as conn:
where_clauses = ["bank_id = $1"]
params: list[Any] = [bank_id]
idx = 2
if action:
where_clauses.append(f"action = ${idx}")
params.append(action)
idx += 1
if transport:
where_clauses.append(f"transport = ${idx}")
params.append(transport)
idx += 1
if start_date:
parsed_start = datetime.fromisoformat(start_date.replace("Z", "+00:00"))
where_clauses.append(f"started_at >= ${idx}")
params.append(parsed_start)
idx += 1
if end_date:
parsed_end = datetime.fromisoformat(end_date.replace("Z", "+00:00"))
where_clauses.append(f"started_at < ${idx}")
params.append(parsed_end)
idx += 1
where_sql = " AND ".join(where_clauses)
table = fq_table("audit_log")
# Get total count
count_row = await conn.fetchrow(
f"SELECT COUNT(*) as total FROM {table} WHERE {where_sql}",
*params,
)
total = count_row["total"] if count_row else 0
# Get paginated results
params.append(limit)
params.append(offset)
rows = await conn.fetch(
f"""
SELECT id, action, transport, bank_id, started_at, ended_at,
request, response, metadata
FROM {table}
WHERE {where_sql}
ORDER BY started_at DESC
LIMIT ${idx} OFFSET ${idx + 1}
""",
*params,
)
items = []
for row in rows:
duration_ms = None
started = row["started_at"]
ended = row["ended_at"]
if started and ended and hasattr(started, "total_seconds"):
duration_ms = int((ended - started).total_seconds() * 1000)
elif started and ended:
try:
duration_ms = int((ended - started).total_seconds() * 1000)
except (TypeError, AttributeError):
pass
def _safe_iso(val):
if val is None:
return None
return val.isoformat() if hasattr(val, "isoformat") else str(val)
def _safe_json(val):
if val is None:
return None
if isinstance(val, dict):
return val
return json.loads(val) if isinstance(val, str) else val
items.append(
{
"id": str(row["id"]),
"action": row["action"],
"transport": row["transport"],
"bank_id": row["bank_id"],
"started_at": _safe_iso(started),
"ended_at": _safe_iso(ended),
"duration_ms": duration_ms,
"request": _safe_json(row["request"]),
"response": _safe_json(row["response"]),
"metadata": _safe_json(row["metadata"]) or {},
}
)
return {
"bank_id": bank_id,
"total": total,
"limit": limit,
"offset": offset,
"items": items,
}
return result
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
@@ -6405,72 +6449,15 @@ def _register_routes(app: FastAPI):
):
"""Get audit log counts grouped by time bucket."""
try:
from hindsight_api.engine.db_utils import acquire_with_retry
from hindsight_api.engine.memory_engine import fq_table
pool = await app.state.memory._get_backend()
# Read endpoint: verify bank exists without auto-creating it.
if (
await app.state.memory.get_bank_profile(
bank_id, request_context=request_context, create_if_missing=False
)
is None
):
result = await app.state.memory.audit_log_stats(
bank_id,
request_context=request_context,
action=action,
period=period,
)
if result is None:
raise HTTPException(status_code=404, detail=f"Bank '{bank_id}' not found")
# Determine time range (always per-day buckets)
from datetime import timedelta as _td
now = datetime.now(timezone.utc)
trunc = "day"
if period == "1d":
start = now - _td(days=1)
elif period == "30d":
start = now - _td(days=30)
else: # 7d default
start = now - _td(days=7)
table = fq_table("audit_log")
async with acquire_with_retry(pool) as conn:
where_clauses = ["bank_id = $1", "started_at >= $2"]
params: list[Any] = [bank_id, start]
idx = 3
if action:
where_clauses.append(f"action = ${idx}")
params.append(action)
idx += 1
where_sql = " AND ".join(where_clauses)
rows = await conn.fetch(
f"""
SELECT date_trunc('{trunc}', started_at) AS bucket,
action,
COUNT(*) AS count
FROM {table}
WHERE {where_sql}
GROUP BY bucket, action
ORDER BY bucket ASC
""",
*params,
)
buckets: dict[str, dict[str, int]] = {}
for row in rows:
bucket_key = row["bucket"].isoformat()
if bucket_key not in buckets:
buckets[bucket_key] = {}
buckets[bucket_key][row["action"]] = row["count"]
return {
"bank_id": bank_id,
"period": period,
"trunc": trunc,
"start": start.isoformat(),
"buckets": [{"time": k, "actions": v, "total": sum(v.values())} for k, v in buckets.items()],
}
return result
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
@@ -6480,3 +6467,98 @@ def _register_routes(app: FastAPI):
logger.error(f"Error getting audit log stats: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/llm-requests",
summary="List LLM request traces",
description="List traced LLM requests for a bank, ordered by most recent first. "
"Requires LLM request tracing to be enabled (HINDSIGHT_API_LLM_TRACE_ENABLED).",
operation_id="list_llm_requests",
tags=["LLM Traces"],
response_model=LLMRequestListResponse,
)
async def api_list_llm_requests(
bank_id: str,
status: str | None = Query(None, description="Filter by status (success, error)"),
operation: str | None = Query(None, description="Filter by operation (retain, reflect, consolidation)"),
scope: str | None = Query(None, description="Filter by call scope"),
provider: str | None = Query(None, description="Filter by LLM provider"),
trace_id: str | None = Query(None, description="Filter to one operation run (all LLM calls sharing a trace)"),
document_id: str | None = Query(None, description="Filter to LLM calls that processed a given document"),
memory_id: str | None = Query(
None, description="Filter to the operation run(s) that produced or consumed a given memory_unit"
),
group: bool = Query(
False, description="Paginate by operation run (trace) instead of by call; returns whole runs"
),
start_date: str | None = Query(None, description="Filter from this ISO datetime (inclusive)"),
end_date: str | None = Query(None, description="Filter until this ISO datetime (exclusive)"),
limit: int = Query(50, ge=1, le=500, description="Max items to return"),
offset: int = Query(0, ge=0, description="Offset for pagination"),
request_context: RequestContext = Depends(get_request_context),
):
"""List traced LLM requests for a bank."""
try:
result = await app.state.memory.list_llm_requests(
bank_id,
request_context=request_context,
status=status,
operation=operation,
scope=scope,
provider=provider,
trace_id=trace_id,
document_id=document_id,
memory_id=memory_id,
group=group,
start_date=datetime.fromisoformat(start_date.replace("Z", "+00:00")) if start_date else None,
end_date=datetime.fromisoformat(end_date.replace("Z", "+00:00")) if end_date else None,
limit=limit,
offset=offset,
)
if result is None:
raise HTTPException(status_code=404, detail=f"Bank '{bank_id}' not found")
return result
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
logger.error(f"Error listing LLM requests: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/llm-requests/stats",
summary="LLM request statistics",
description="Get LLM request counts grouped by time bucket and status for charting.",
operation_id="llm_request_stats",
tags=["LLM Traces"],
response_model=LLMRequestStatsResponse,
)
async def api_llm_request_stats(
bank_id: str,
operation: str | None = Query(None, description="Filter by operation"),
period: str = Query("7d", description="Time period: 1d, 7d, or 30d"),
request_context: RequestContext = Depends(get_request_context),
):
"""Get LLM request counts grouped by time bucket and status."""
try:
result = await app.state.memory.llm_request_stats(
bank_id,
request_context=request_context,
operation=operation,
period=period,
)
if result is None:
raise HTTPException(status_code=404, detail=f"Bank '{bank_id}' not found")
return result
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
logger.error(f"Error getting LLM request stats: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=str(e))
+310 -8
View File
@@ -143,6 +143,7 @@ ENV_LLM_GROQ_SERVICE_TIER = "HINDSIGHT_API_LLM_GROQ_SERVICE_TIER"
ENV_LLM_OPENAI_SERVICE_TIER = "HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER"
ENV_LLM_EXTRA_BODY = "HINDSIGHT_API_LLM_EXTRA_BODY"
ENV_LLM_DEFAULT_HEADERS = "HINDSIGHT_API_LLM_DEFAULT_HEADERS"
ENV_LLM_STRICT_SCHEMA = "HINDSIGHT_API_LLM_STRICT_SCHEMA"
# LiteLLM Router chain — provider-specific config consumed by the "litellmrouter"
# provider. Each entry is a deployment; the Router tries them in declared order and
@@ -209,6 +210,17 @@ ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
ENV_EMBEDDINGS_LOCAL_FORCE_CPU = "HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE = "HINDSIGHT_API_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE"
ENV_EMBEDDINGS_ONNX_MODEL_ID = "HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_ID"
ENV_EMBEDDINGS_ONNX_MODEL_PATH = "HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_PATH"
ENV_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH = "HINDSIGHT_API_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH"
ENV_EMBEDDINGS_ONNX_FILE = "HINDSIGHT_API_EMBEDDINGS_ONNX_FILE"
ENV_EMBEDDINGS_ONNX_DIMENSIONS = "HINDSIGHT_API_EMBEDDINGS_ONNX_DIMENSIONS"
ENV_EMBEDDINGS_ONNX_MAX_TOKENS = "HINDSIGHT_API_EMBEDDINGS_ONNX_MAX_TOKENS"
ENV_EMBEDDINGS_ONNX_POOLING = "HINDSIGHT_API_EMBEDDINGS_ONNX_POOLING"
ENV_EMBEDDINGS_ONNX_NORMALIZE = "HINDSIGHT_API_EMBEDDINGS_ONNX_NORMALIZE"
ENV_EMBEDDINGS_ONNX_QUERY_PREFIX = "HINDSIGHT_API_EMBEDDINGS_ONNX_QUERY_PREFIX"
ENV_EMBEDDINGS_ONNX_PASSAGE_PREFIX = "HINDSIGHT_API_EMBEDDINGS_ONNX_PASSAGE_PREFIX"
ENV_EMBEDDINGS_ONNX_OUTPUT_NAME = "HINDSIGHT_API_EMBEDDINGS_ONNX_OUTPUT_NAME"
ENV_EMBEDDINGS_TEI_URL = "HINDSIGHT_API_EMBEDDINGS_TEI_URL"
ENV_EMBEDDINGS_OPENAI_API_KEY = "HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY"
ENV_EMBEDDINGS_OPENAI_MODEL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL"
@@ -297,6 +309,7 @@ ENV_RERANKER_LITELLM_TIMEOUT = "HINDSIGHT_API_RERANKER_LITELLM_TIMEOUT"
ENV_RERANKER_LITELLM_SDK_TIMEOUT = "HINDSIGHT_API_RERANKER_LITELLM_SDK_TIMEOUT"
ENV_RERANKER_GOOGLE_TIMEOUT = "HINDSIGHT_API_RERANKER_GOOGLE_TIMEOUT"
ENV_RERANKER_MAX_CANDIDATES = "HINDSIGHT_API_RERANKER_MAX_CANDIDATES"
ENV_SEMANTIC_MIN_SIMILARITY = "HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY"
ENV_RERANKER_FLASHRANK_MODEL = "HINDSIGHT_API_RERANKER_FLASHRANK_MODEL"
ENV_RERANKER_FLASHRANK_CACHE_DIR = "HINDSIGHT_API_RERANKER_FLASHRANK_CACHE_DIR"
ENV_RERANKER_FLASHRANK_CPU_MEM_ARENA = "HINDSIGHT_API_RERANKER_FLASHRANK_CPU_MEM_ARENA"
@@ -346,6 +359,8 @@ ENV_RECALL_MAX_QUERY_TOKENS = "HINDSIGHT_API_RECALL_MAX_QUERY_TOKENS"
ENV_MENTAL_MODEL_REFRESH_CONCURRENCY = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY"
ENV_LINK_EXPANSION_PER_ENTITY_LIMIT = "HINDSIGHT_API_LINK_EXPANSION_PER_ENTITY_LIMIT"
ENV_LINK_EXPANSION_TIMEOUT = "HINDSIGHT_API_LINK_EXPANSION_TIMEOUT"
ENV_BANK_STATS_CACHE_TTL_SECONDS = "HINDSIGHT_API_BANK_STATS_CACHE_TTL_SECONDS"
ENV_BANK_STATS_CACHE_MAX_ENTRIES = "HINDSIGHT_API_BANK_STATS_CACHE_MAX_ENTRIES"
# OpenTelemetry tracing configuration
ENV_OTEL_TRACES_ENABLED = "HINDSIGHT_API_OTEL_TRACES_ENABLED"
@@ -363,6 +378,16 @@ ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOU
# Gemini safety settings
ENV_LLM_GEMINI_SAFETY_SETTINGS = "HINDSIGHT_API_LLM_GEMINI_SAFETY_SETTINGS"
# Gemini prompt caching. When enabled, retain fact-extraction reuses a
# CachedContent prefix for the static system_instruction + response_schema,
# cutting per-call input cost on workloads with many small documents.
# Provider-agnostic prompt-prefix caching. Providers that support it (currently
# Gemini/Vertex via CachedContent) reuse the large, fixed, bank-agnostic system
# prefix at the cached-input rate; providers that don't simply ignore it. On by
# default — the prefix is bank-agnostic so a single cache is shared across all
# banks, and creation soft-fails to an uncached call, so it never breaks a request.
ENV_LLM_PROMPT_CACHE_ENABLED = "HINDSIGHT_API_LLM_PROMPT_CACHE_ENABLED"
# Retain settings
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
@@ -400,12 +425,17 @@ ENV_FILE_CONVERSION_MAX_BATCH_SIZE = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SI
ENV_ENABLE_FILE_UPLOAD_API = "HINDSIGHT_API_ENABLE_FILE_UPLOAD_API"
ENV_FILE_DELETE_AFTER_RETAIN = "HINDSIGHT_API_FILE_DELETE_AFTER_RETAIN"
# Document transfer (export/import documents between banks without re-running the LLM)
ENV_ENABLE_DOCUMENT_EXPORT_API = "HINDSIGHT_API_ENABLE_DOCUMENT_EXPORT_API"
ENV_ENABLE_DOCUMENT_IMPORT_API = "HINDSIGHT_API_ENABLE_DOCUMENT_IMPORT_API"
# Observations settings (consolidated knowledge from facts)
ENV_ENABLE_OBSERVATIONS = "HINDSIGHT_API_ENABLE_OBSERVATIONS"
ENV_ENABLE_AUTO_CONSOLIDATION = "HINDSIGHT_API_ENABLE_AUTO_CONSOLIDATION"
ENV_CONSOLIDATION_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE"
ENV_CONSOLIDATION_MAX_MEMORIES_PER_ROUND = "HINDSIGHT_API_CONSOLIDATION_MAX_MEMORIES_PER_ROUND"
ENV_CONSOLIDATION_LLM_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_LLM_BATCH_SIZE"
ENV_CONSOLIDATION_DEDUP_THRESHOLD = "HINDSIGHT_API_CONSOLIDATION_DEDUP_THRESHOLD"
ENV_CONSOLIDATION_LLM_PARALLELISM = "HINDSIGHT_API_CONSOLIDATION_LLM_PARALLELISM"
ENV_CONSOLIDATION_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_MAX_TOKENS"
ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS"
@@ -417,6 +447,7 @@ ENV_CONSOLIDATION_MAX_ATTEMPTS = "HINDSIGHT_API_CONSOLIDATION_MAX_ATTEMPTS"
ENV_OBSERVATIONS_MISSION = "HINDSIGHT_API_OBSERVATIONS_MISSION"
ENV_MAX_OBSERVATIONS_PER_SCOPE = "HINDSIGHT_API_MAX_OBSERVATIONS_PER_SCOPE"
ENV_ENABLE_OBSERVATION_HISTORY = "HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY"
ENV_OBSERVATION_HISTORY_MAX_ENTRIES = "HINDSIGHT_API_OBSERVATION_HISTORY_MAX_ENTRIES"
ENV_ENABLE_MENTAL_MODEL_HISTORY = "HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY"
ENV_MENTAL_MODEL_HISTORY_MAX_ENTRIES = "HINDSIGHT_API_MENTAL_MODEL_HISTORY_MAX_ENTRIES"
@@ -448,6 +479,11 @@ ENV_DB_COMMAND_TIMEOUT = "HINDSIGHT_API_DB_COMMAND_TIMEOUT"
ENV_DB_ACQUIRE_TIMEOUT = "HINDSIGHT_API_DB_ACQUIRE_TIMEOUT"
ENV_DB_STATEMENT_TIMEOUT = "HINDSIGHT_API_DB_STATEMENT_TIMEOUT"
# Wall-clock cap on model/connection initialization at startup. If embeddings,
# cross-encoder, or LLM verification hang (e.g. an offline HuggingFace download
# or an unreachable provider), the daemon fails fast instead of hanging forever.
ENV_MODEL_INIT_TIMEOUT = "HINDSIGHT_API_MODEL_INIT_TIMEOUT"
# Worker configuration (distributed task processing)
ENV_WORKER_ENABLED = "HINDSIGHT_API_WORKER_ENABLED"
ENV_WORKER_ID = "HINDSIGHT_API_WORKER_ID"
@@ -468,6 +504,7 @@ WORKER_SLOT_RESERVATION_TYPES: dict[str, tuple[str, int]] = {
"file_convert_retain": ("HINDSIGHT_API_WORKER_FILE_CONVERT_RETAIN_MAX_SLOTS", 0),
"refresh_mental_model": ("HINDSIGHT_API_WORKER_REFRESH_MENTAL_MODEL_MAX_SLOTS", 0),
"graph_maintenance": ("HINDSIGHT_API_WORKER_GRAPH_MAINTENANCE_MAX_SLOTS", 0),
"import_documents": ("HINDSIGHT_API_WORKER_IMPORT_DOCUMENTS_MAX_SLOTS", 0),
}
ENV_WORKER_CONSOLIDATION_BANK_PRIORITY = "HINDSIGHT_API_WORKER_CONSOLIDATION_BANK_PRIORITY"
ENV_RETAIN_MAX_CONCURRENT = "HINDSIGHT_API_RETAIN_MAX_CONCURRENT"
@@ -493,11 +530,32 @@ ENV_RECALL_BUDGET_ADAPTIVE_HIGH = "HINDSIGHT_API_RECALL_BUDGET_ADAPTIVE_HIGH"
ENV_RECALL_BUDGET_MIN = "HINDSIGHT_API_RECALL_BUDGET_MIN"
ENV_RECALL_BUDGET_MAX = "HINDSIGHT_API_RECALL_BUDGET_MAX"
# Recall candidate gating (per-source cap + BM25 score floor)
ENV_BM25_MIN_SCORE = "HINDSIGHT_API_BM25_MIN_SCORE"
ENV_RECALL_MAX_CANDIDATES_PER_SOURCE = "HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE"
# Per-strategy recall boost. Prioritises specific retrieval arms (semantic,
# bm25, graph, temporal) on recall via a human priority level — e.g.
# "graph:high" to strongly favour graph hits, or "graph:high,semantic:low".
# Valid levels: low | medium | high. The level (not a raw number) is the knob
# because the boost is applied on two different score scales — see
# engine/search/recall_boost.py for the level -> magnitude mapping and rationale.
# Empty disables the feature.
ENV_RECALL_STRATEGY_BOOSTS = "HINDSIGHT_API_RECALL_STRATEGY_BOOSTS"
# Audit log settings
ENV_AUDIT_LOG_ENABLED = "HINDSIGHT_API_AUDIT_LOG_ENABLED"
ENV_AUDIT_LOG_ACTIONS = "HINDSIGHT_API_AUDIT_LOG_ACTIONS"
ENV_AUDIT_LOG_RETENTION_DAYS = "HINDSIGHT_API_AUDIT_LOG_RETENTION_DAYS"
# LLM request tracing settings
ENV_LLM_TRACE_ENABLED = "HINDSIGHT_API_LLM_TRACE_ENABLED"
ENV_LLM_TRACE_SCOPES = "HINDSIGHT_API_LLM_TRACE_SCOPES"
ENV_LLM_TRACE_RETENTION_DAYS = "HINDSIGHT_API_LLM_TRACE_RETENTION_DAYS"
ENV_LLM_TRACE_MAX_CHARS = "HINDSIGHT_API_LLM_TRACE_MAX_CHARS"
# Background maintenance settings
ENV_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS = "HINDSIGHT_API_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS"
# Disposition settings
ENV_DISPOSITION_SKEPTICISM = "HINDSIGHT_API_DISPOSITION_SKEPTICISM"
ENV_DISPOSITION_LITERALISM = "HINDSIGHT_API_DISPOSITION_LITERALISM"
@@ -513,9 +571,9 @@ DEFAULT_LLM_PROVIDER = "openai"
PROVIDER_DEFAULT_MODELS = {
"openai": "gpt-4o-mini",
"anthropic": "claude-haiku-4-5",
"gemini": "gemini-2.5-flash",
"gemini": "gemini-3.5-flash",
"groq": "openai/gpt-oss-120b",
"minimax": "MiniMax-M2.7",
"minimax": "MiniMax-M3",
"deepseek": "deepseek-v4-flash",
"zai": "glm-4.5-flash",
"opencode-go": "deepseek-v4-flash",
@@ -523,7 +581,7 @@ PROVIDER_DEFAULT_MODELS = {
"ollama-cloud": "gemma3:12b",
"llamacpp": "gemma-4-e2b-it",
"lmstudio": "local-model",
"vertexai": "google/gemini-2.5-flash-lite",
"vertexai": "google/gemini-3.1-flash-lite",
"openai-codex": "gpt-5.4-mini",
"claude-code": "claude-sonnet-4-5-20250929",
"mock": "mock-model",
@@ -542,6 +600,14 @@ DEFAULT_LLAMACPP_CHAT_FORMAT = None # None = auto-detect from GGUF metadata
DEFAULT_LLAMACPP_NO_GRAMMAR = False # True = disable JSON grammar enforcement (faster but less reliable)
DEFAULT_LLAMACPP_EXTRA_ARGS = None # Space-separated extra CLI args for llama.cpp server
# True = ask schema-capable backends to grammar-enforce structured output via
# json_schema strict (OpenAI-compatible, LiteLLM; Gemini already enforces its
# native response_schema). Default False keeps the soft "schema-in-prompt +
# json_object" path, which weaker self-hosted instruction-followers can violate
# (prose preambles, markdown fences, invalid JSON) — wedging retain/consolidation
# on parse retries.
DEFAULT_LLM_STRICT_SCHEMA = False
DEFAULT_LLM_MAX_CONCURRENT = 32
DEFAULT_LLM_MAX_RETRIES = 3 # Max retry attempts for LLM API calls
DEFAULT_LLM_INITIAL_BACKOFF = 1.0 # Initial backoff in seconds for retry exponential backoff
@@ -561,6 +627,13 @@ DEFAULT_EMBEDDINGS_PROVIDER = "local"
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE = False # Security: disabled by default, required for some models
DEFAULT_EMBEDDINGS_ONNX_MODEL_ID = "intfloat/multilingual-e5-small"
DEFAULT_EMBEDDINGS_ONNX_FILE = "onnx/model.onnx"
DEFAULT_EMBEDDINGS_ONNX_MAX_TOKENS = 512
DEFAULT_EMBEDDINGS_ONNX_POOLING = "mean"
DEFAULT_EMBEDDINGS_ONNX_NORMALIZE = True
DEFAULT_EMBEDDINGS_ONNX_QUERY_PREFIX = "query: "
DEFAULT_EMBEDDINGS_ONNX_PASSAGE_PREFIX = "passage: "
DEFAULT_EMBEDDINGS_OPENAI_MODEL = "text-embedding-3-small"
DEFAULT_EMBEDDINGS_OPENAI_BATCH_SIZE = 100
DEFAULT_EMBEDDINGS_GEMINI_MODEL = "gemini-embedding-001"
@@ -592,6 +665,65 @@ DEFAULT_RERANKER_LITELLM_TIMEOUT = 60.0
DEFAULT_RERANKER_LITELLM_SDK_TIMEOUT = 60.0
DEFAULT_RERANKER_GOOGLE_TIMEOUT = 60.0
DEFAULT_RERANKER_MAX_CANDIDATES = 300
DEFAULT_SEMANTIC_MIN_SIMILARITY = 0.3
# Minimum BM25 score a row must exceed to enter fusion. 0.0 gates out
# zero-score (non-matching) rows on backends — notably VectorChord — whose
# operator ranks every document rather than pre-filtering to term matches.
DEFAULT_BM25_MIN_SCORE = 0.0
# Per-source candidate cap applied to each retrieval arm (semantic, BM25, graph,
# temporal) before RRF, so a single over-expanding backend cannot fill the
# reranker's global candidate budget on its own. 0 disables the cap.
DEFAULT_RECALL_MAX_CANDIDATES_PER_SOURCE = 0
# Per-strategy recall boost, as a comma-separated "strategy:level" list (e.g.
# "graph:high,semantic:low"). Empty disables the feature. See
# ENV_RECALL_STRATEGY_BOOSTS for the full rationale.
DEFAULT_RECALL_STRATEGY_BOOSTS = ""
# Retrieval arms that can be boosted; mirrors fusion.py source_names.
RECALL_STRATEGY_NAMES = ("semantic", "bm25", "graph", "temporal")
# User-facing priority levels. Kept in sync with recall_boost.BOOST_LEVELS by a
# guard test; defined here (not imported) so config stays free of the heavy
# engine.search import graph.
RECALL_BOOST_LEVELS = ("low", "medium", "high")
# Level applied when a strategy is listed without one (e.g. "graph" or "graph:").
DEFAULT_RECALL_BOOST_LEVEL = "medium"
def _parse_strategy_boosts(raw: str | None) -> dict[str, str]:
"""Parse a "strategy:level,strategy:level" string into a boost map.
A strategy listed without a level (``"graph"`` or ``"graph:"``) defaults to
``medium``. Only the strategies you list are boosted; any strategy you omit
keeps its normal, unboosted weight. Unknown strategy names, unknown levels,
and malformed entries are skipped with a warning so a typo degrades to a
no-op boost rather than breaking recall.
"""
if not raw or not raw.strip():
return {}
boosts: dict[str, str] = {}
for entry in raw.split(","):
entry = entry.strip()
if not entry:
continue
name, _sep, level = entry.partition(":")
name = name.strip().lower()
level = level.strip().lower() or DEFAULT_RECALL_BOOST_LEVEL
if name not in RECALL_STRATEGY_NAMES:
logger.warning(
"Ignoring unknown recall strategy %r in boost (valid: %s)", name, ", ".join(RECALL_STRATEGY_NAMES)
)
continue
if level not in RECALL_BOOST_LEVELS:
logger.warning(
"Ignoring unknown recall boost level %r for %r (valid: %s)",
level,
name,
", ".join(RECALL_BOOST_LEVELS),
)
continue
boosts[name] = level
return boosts
DEFAULT_RERANKER_FLASHRANK_MODEL = "ms-marco-MiniLM-L-12-v2" # Best balance of speed and quality
DEFAULT_RERANKER_FLASHRANK_CACHE_DIR = None # Use default cache directory
DEFAULT_RERANKER_FLASHRANK_CPU_MEM_ARENA = False # Disable ONNX CPU memory arena to bound RSS
@@ -667,6 +799,8 @@ DEFAULT_RECALL_MAX_QUERY_TOKENS = 500 # Maximum tokens allowed in recall query
DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY = 8 # Max concurrent mental model refreshes
DEFAULT_LINK_EXPANSION_PER_ENTITY_LIMIT = 200 # Max target units per entity in graph expansion
DEFAULT_LINK_EXPANSION_TIMEOUT = 10.0 # Timeout (seconds) for entity expansion query
DEFAULT_BANK_STATS_CACHE_TTL_SECONDS = 60.0 # TTL for get_bank_stats result cache; 0 disables
DEFAULT_BANK_STATS_CACHE_MAX_ENTRIES = 1024 # LRU bound across (schema, bank) keys
# Retain settings
DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction LLM call
@@ -685,6 +819,7 @@ DEFAULT_RETAIN_BATCH_TOKENS = 10_000 # ~40KB of text # Max chars per sub-batch
DEFAULT_RETAIN_ENTITY_LOOKUP = "trigram" # "full" or "trigram"
DEFAULT_RETAIN_ENTITY_RESOLUTION_BATCH_SIZE = 100 # Unique entity names per pg_trgm candidate lookup query
DEFAULT_RETAIN_BATCH_ENABLED = False # Use LLM Batch API for fact extraction (only when async=True)
DEFAULT_LLM_PROMPT_CACHE_ENABLED = True # Reuse the fixed system prefix via provider prompt caching
DEFAULT_RETAIN_BATCH_POLL_INTERVAL_SECONDS = 60 # Batch API polling interval in seconds
# File storage defaults
@@ -696,23 +831,36 @@ DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE = 10 # Max files per batch upload
DEFAULT_ENABLE_FILE_UPLOAD_API = True # Enable file upload endpoint
DEFAULT_FILE_DELETE_AFTER_RETAIN = True # Delete file bytes after retain (saves storage)
# Document transfer defaults (export/import enabled by default; gated independently)
DEFAULT_ENABLE_DOCUMENT_EXPORT_API = True
DEFAULT_ENABLE_DOCUMENT_IMPORT_API = True
# Observations defaults (consolidated knowledge from facts)
DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default
DEFAULT_ENABLE_AUTO_CONSOLIDATION = True # Auto-consolidation after retain enabled by default
DEFAULT_ENABLE_OBSERVATION_HISTORY = True # Observation history tracking enabled by default
DEFAULT_ENABLE_MENTAL_MODEL_HISTORY = True # Mental model history tracking enabled by default
# Each history entry snapshots previous_content + previous_reflect_response. Without
# a cap, sustained mental-model refresh load grows the jsonb array unboundedly until
# it crosses Postgres's hard 256MB jsonb limit and subsequent UPDATEs fail with
# SQLSTATE 54000. 50 keeps the array well under 100MB even with large reflect
# responses, while preserving enough recent history for meaningful audit / rollback.
# History (mental-model refresh snapshots and observation update snapshots) lives in
# the dedicated mental_model_history / observation_history tables, one row per change.
# On every write we insert the new entry and delete the oldest rows beyond the cap,
# so the per-item history can never grow unboundedly (the old single-JSONB-column
# design hit Postgres's hard 256MB jsonb limit -> SQLSTATE 54000 and stuck rows).
# 50 preserves enough recent history for meaningful audit / rollback per item.
# A cap <= 0 removes the trim (unbounded growth) — to turn history OFF use the
# enable_* flag, not a zero cap.
DEFAULT_MENTAL_MODEL_HISTORY_MAX_ENTRIES = 50
DEFAULT_OBSERVATION_HISTORY_MAX_ENTRIES = 50
DEFAULT_CONSOLIDATION_MAX_ATTEMPTS = 3 # Outer retry attempts for consolidation LLM batch calls
DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization)
DEFAULT_CONSOLIDATION_MAX_MEMORIES_PER_ROUND = (
100 # Max memories per consolidation round (0 = unlimited). Limits how long one bank holds a worker slot.
)
DEFAULT_CONSOLIDATION_LLM_BATCH_SIZE = 8 # Facts per LLM call (1 = no batching; >1 = batch mode)
# Cosine >= this between a newly-created or freshly-updated observation and an existing one
# triggers a focused 1-by-1 LLM "merge or keep" pass (the LLM reads both, so numbers/negation/
# entities are respected). Enabled by default; set to 1.0 to disable. Postgres only — the merge
# path uses Postgres-only SQL, so consolidation skips it on Oracle regardless of this value.
DEFAULT_CONSOLIDATION_DEDUP_THRESHOLD = 0.97
DEFAULT_CONSOLIDATION_LLM_PARALLELISM = (
4 # Max tag groups consolidated concurrently per op. Locks on overlapping write
# scopes degrade to sequential automatically; matches retain_max_concurrent.
@@ -737,6 +885,7 @@ DEFAULT_DB_POOL_MAX_SIZE = 100
DEFAULT_DB_COMMAND_TIMEOUT = 60 # seconds
DEFAULT_DB_ACQUIRE_TIMEOUT = 30 # seconds
DEFAULT_DB_STATEMENT_TIMEOUT = 600 # seconds (Postgres statement_timeout applied on every pool connection; 0 disables)
DEFAULT_MODEL_INIT_TIMEOUT = 300 # seconds (cap on startup model/connection init; covers first-time downloads)
# Worker configuration (distributed task processing)
DEFAULT_WORKER_ENABLED = True # API runs worker by default (standalone mode)
@@ -789,6 +938,18 @@ DEFAULT_AUDIT_LOG_ENABLED = False # Disabled by default
DEFAULT_AUDIT_LOG_ACTIONS = "" # Empty = audit all eligible actions
DEFAULT_AUDIT_LOG_RETENTION_DAYS = -1 # -1 = keep forever
# LLM request tracing defaults
DEFAULT_LLM_TRACE_ENABLED = True # Enabled by default
DEFAULT_LLM_TRACE_SCOPES = "" # Empty = trace all call scopes
DEFAULT_LLM_TRACE_RETENTION_DAYS = 1 # Retain trace rows for 1 day by default
DEFAULT_LLM_TRACE_MAX_CHARS = 50000 # Truncate stored input/output beyond this many chars
# Background maintenance defaults
# Periodic reconcile that re-schedules consolidation for banks with eligible-but-unscheduled
# facts (e.g. after a consolidation operation failed terminally and left them unscheduled).
# 0 disables the reconcile sweep.
DEFAULT_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS = 300
# Default MCP tool descriptions (can be customized via env vars)
DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory.
@@ -1052,6 +1213,7 @@ class HindsightConfig:
llm_default_headers: (
dict | None
) # Custom headers passed as default_headers to provider SDK clients (e.g. {"X-Component-Id": "hindsight"} for proxies / request tracing)
llm_strict_schema: bool # Grammar-enforce structured output via the provider's strongest schema mode (see DEFAULT_LLM_STRICT_SCHEMA)
# LiteLLM Router chain (provider-specific; consumed by the "litellmrouter" provider).
# List of deployment dicts evaluated in order with fallback on transient errors.
@@ -1067,6 +1229,10 @@ class HindsightConfig:
# Gemini safety settings (None = use Gemini defaults; list of dicts with category/threshold)
llm_gemini_safety_settings: list | None
# Gemini prompt caching toggle. When True, retain extraction reuses a
# CachedContent prefix for its system prompt + response schema.
llm_prompt_cache_enabled: bool
# Built-in llama.cpp configuration (for provider=llamacpp)
llamacpp_model_path: str | None # Path to GGUF file (None = auto-download default)
llamacpp_gpu_layers: int # -1 = all layers on GPU, 0 = CPU only
@@ -1119,6 +1285,17 @@ class HindsightConfig:
embeddings_local_model: str
embeddings_local_force_cpu: bool
embeddings_local_trust_remote_code: bool
embeddings_onnx_model_id: str
embeddings_onnx_model_path: str | None
embeddings_onnx_tokenizer_name_or_path: str | None
embeddings_onnx_file: str
embeddings_onnx_dimensions: int | None
embeddings_onnx_max_tokens: int
embeddings_onnx_pooling: str
embeddings_onnx_normalize: bool
embeddings_onnx_query_prefix: str
embeddings_onnx_passage_prefix: str
embeddings_onnx_output_name: str | None
embeddings_tei_url: str | None
embeddings_openai_base_url: str | None
embeddings_cohere_api_key: str | None
@@ -1158,6 +1335,10 @@ class HindsightConfig:
reranker_tei_max_concurrent: int
reranker_tei_http_timeout: float
reranker_max_candidates: int
semantic_min_similarity: float
bm25_min_score: float
recall_max_candidates_per_source: int
recall_strategy_boosts: dict[str, str]
reranker_cohere_api_key: str | None
reranker_cohere_model: str
reranker_cohere_base_url: str | None
@@ -1213,6 +1394,8 @@ class HindsightConfig:
mental_model_refresh_concurrency: int
link_expansion_per_entity_limit: int
link_expansion_timeout: float
bank_stats_cache_ttl_seconds: float
bank_stats_cache_max_entries: int
# Retain settings
retain_max_completion_tokens: int
@@ -1251,14 +1434,18 @@ class HindsightConfig:
file_conversion_max_batch_size: int # Max files per request
enable_file_upload_api: bool
file_delete_after_retain: bool
enable_document_export_api: bool
enable_document_import_api: bool
# Observations settings (consolidated knowledge from facts)
enable_observations: bool
enable_auto_consolidation: bool
enable_observation_history: bool
observation_history_max_entries: int
enable_mental_model_history: bool
mental_model_history_max_entries: int
consolidation_batch_size: int
consolidation_dedup_threshold: float
consolidation_max_memories_per_round: int
consolidation_llm_batch_size: int
consolidation_llm_parallelism: int
@@ -1318,6 +1505,7 @@ class HindsightConfig:
db_command_timeout: int
db_acquire_timeout: int
db_statement_timeout: int
model_init_timeout: float
# Worker configuration (distributed task processing)
worker_enabled: bool
@@ -1349,6 +1537,17 @@ class HindsightConfig:
audit_log_actions: list[str] # Allowlist of action types (empty = all)
audit_log_retention_days: int # -1 = keep forever, >0 = delete after N days
# LLM request tracing configuration (static - server-level only)
llm_trace_enabled: bool # Master switch for per-bank LLM request tracing
llm_trace_scopes: list[str] # Allowlist of call scopes to trace (empty = all)
llm_trace_retention_days: int # -1 = keep forever, >0 = delete after N days
llm_trace_max_chars: int # Truncate stored input/output beyond this many chars
# Background maintenance configuration (static - server-level only)
# Interval for the periodic sweep that re-schedules consolidation for banks with
# eligible-but-unscheduled facts. 0 = disabled.
consolidation_reconcile_interval_seconds: int
# Webhook configuration (static - server-level only, not per-bank)
webhook_url: str | None # Global webhook URL (None = disabled)
webhook_secret: str | None # HMAC signing secret (None = unsigned)
@@ -1549,6 +1748,11 @@ class HindsightConfig:
self.text_search_extension_pg_search_tokenizer
)
if not 0.0 <= self.semantic_min_similarity <= 1.0:
raise ValueError(
f"Invalid semantic_min_similarity: {self.semantic_min_similarity}. Must be between 0.0 and 1.0"
)
# When LLM provider is "none", force chunks-only mode and disable LLM-dependent features
if self.llm_provider == "none":
self.retain_extraction_mode = "chunks"
@@ -1597,6 +1801,21 @@ class HindsightConfig:
" and ".join(missing),
)
if self.embeddings_provider == "onnx":
try:
import importlib
importlib.import_module("onnxruntime")
importlib.import_module("transformers")
except ImportError:
logger.warning(
"ONNX embeddings provider configured, but 'onnxruntime' and/or "
"'transformers' is not installed. The API will fail at model init time. Either:\n"
" 1. Install ONNX deps: pip install hindsight-api-slim[local-onnx]\n"
" 2. Use a different embeddings provider, e.g. HINDSIGHT_API_EMBEDDINGS_PROVIDER=local "
"or openai"
)
# Validate that sum of per-operation slot reservations does not exceed max_slots
total_reserved = sum(self.worker_slot_reservations.values())
if total_reserved > self.worker_max_slots:
@@ -1649,6 +1868,7 @@ class HindsightConfig:
llm_openai_service_tier=os.getenv(ENV_LLM_OPENAI_SERVICE_TIER, DEFAULT_LLM_OPENAI_SERVICE_TIER),
llm_extra_body=json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null")),
llm_default_headers=json.loads(os.getenv(ENV_LLM_DEFAULT_HEADERS, "null")),
llm_strict_schema=os.getenv(ENV_LLM_STRICT_SCHEMA, str(DEFAULT_LLM_STRICT_SCHEMA)).lower() in ("true", "1"),
llm_litellmrouter_config=_parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG),
# Vertex AI
llm_vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or DEFAULT_LLM_VERTEXAI_PROJECT_ID,
@@ -1657,6 +1877,10 @@ class HindsightConfig:
or DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY,
# Gemini safety settings (JSON-encoded list of {category, threshold} dicts)
llm_gemini_safety_settings=json.loads(os.getenv(ENV_LLM_GEMINI_SAFETY_SETTINGS, "null")),
llm_prompt_cache_enabled=os.getenv(
ENV_LLM_PROMPT_CACHE_ENABLED, str(DEFAULT_LLM_PROMPT_CACHE_ENABLED)
).lower()
in ("1", "true", "yes", "on"),
# Built-in llama.cpp configuration
llamacpp_model_path=os.getenv(ENV_LLAMACPP_MODEL_PATH) or None,
llamacpp_gpu_layers=int(os.getenv(ENV_LLAMACPP_GPU_LAYERS, str(DEFAULT_LLAMACPP_GPU_LAYERS))),
@@ -1755,6 +1979,36 @@ class HindsightConfig:
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE, str(DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE)
).lower()
in ("true", "1"),
embeddings_onnx_model_id=os.getenv(ENV_EMBEDDINGS_ONNX_MODEL_ID, DEFAULT_EMBEDDINGS_ONNX_MODEL_ID),
embeddings_onnx_model_path=os.getenv(ENV_EMBEDDINGS_ONNX_MODEL_PATH) or None,
embeddings_onnx_tokenizer_name_or_path=os.getenv(ENV_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH) or None,
embeddings_onnx_file=os.getenv(ENV_EMBEDDINGS_ONNX_FILE, DEFAULT_EMBEDDINGS_ONNX_FILE),
embeddings_onnx_dimensions=_parse_optional_positive_int(
ENV_EMBEDDINGS_ONNX_DIMENSIONS,
os.getenv(ENV_EMBEDDINGS_ONNX_DIMENSIONS),
),
embeddings_onnx_max_tokens=_parse_positive_int(
ENV_EMBEDDINGS_ONNX_MAX_TOKENS,
os.getenv(ENV_EMBEDDINGS_ONNX_MAX_TOKENS),
DEFAULT_EMBEDDINGS_ONNX_MAX_TOKENS,
),
embeddings_onnx_pooling=_parse_optional_choice(
ENV_EMBEDDINGS_ONNX_POOLING,
os.getenv(ENV_EMBEDDINGS_ONNX_POOLING),
frozenset({"mean", "cls"}),
)
or DEFAULT_EMBEDDINGS_ONNX_POOLING,
embeddings_onnx_normalize=os.getenv(
ENV_EMBEDDINGS_ONNX_NORMALIZE, str(DEFAULT_EMBEDDINGS_ONNX_NORMALIZE)
).lower()
in ("true", "1"),
embeddings_onnx_query_prefix=os.getenv(
ENV_EMBEDDINGS_ONNX_QUERY_PREFIX, DEFAULT_EMBEDDINGS_ONNX_QUERY_PREFIX
),
embeddings_onnx_passage_prefix=os.getenv(
ENV_EMBEDDINGS_ONNX_PASSAGE_PREFIX, DEFAULT_EMBEDDINGS_ONNX_PASSAGE_PREFIX
),
embeddings_onnx_output_name=os.getenv(ENV_EMBEDDINGS_ONNX_OUTPUT_NAME) or None,
embeddings_tei_url=os.getenv(ENV_EMBEDDINGS_TEI_URL),
embeddings_openai_base_url=os.getenv(ENV_EMBEDDINGS_OPENAI_BASE_URL) or None,
embeddings_openai_batch_size=_parse_positive_int(
@@ -1876,6 +2130,14 @@ class HindsightConfig:
os.getenv(ENV_RERANKER_TEI_HTTP_TIMEOUT, str(DEFAULT_RERANKER_TEI_HTTP_TIMEOUT))
),
reranker_max_candidates=int(os.getenv(ENV_RERANKER_MAX_CANDIDATES, str(DEFAULT_RERANKER_MAX_CANDIDATES))),
semantic_min_similarity=float(os.getenv(ENV_SEMANTIC_MIN_SIMILARITY, str(DEFAULT_SEMANTIC_MIN_SIMILARITY))),
bm25_min_score=float(os.getenv(ENV_BM25_MIN_SCORE, str(DEFAULT_BM25_MIN_SCORE))),
recall_max_candidates_per_source=int(
os.getenv(ENV_RECALL_MAX_CANDIDATES_PER_SOURCE, str(DEFAULT_RECALL_MAX_CANDIDATES_PER_SOURCE))
),
recall_strategy_boosts=_parse_strategy_boosts(
os.getenv(ENV_RECALL_STRATEGY_BOOSTS, DEFAULT_RECALL_STRATEGY_BOOSTS)
),
# Cohere reranker (with backward-compatible fallback to shared API key)
reranker_cohere_api_key=os.getenv(ENV_RERANKER_COHERE_API_KEY) or os.getenv(ENV_COHERE_API_KEY),
reranker_cohere_model=os.getenv(ENV_RERANKER_COHERE_MODEL, DEFAULT_RERANKER_COHERE_MODEL),
@@ -1965,6 +2227,12 @@ class HindsightConfig:
os.getenv(ENV_LINK_EXPANSION_PER_ENTITY_LIMIT, str(DEFAULT_LINK_EXPANSION_PER_ENTITY_LIMIT))
),
link_expansion_timeout=float(os.getenv(ENV_LINK_EXPANSION_TIMEOUT, str(DEFAULT_LINK_EXPANSION_TIMEOUT))),
bank_stats_cache_ttl_seconds=float(
os.getenv(ENV_BANK_STATS_CACHE_TTL_SECONDS, str(DEFAULT_BANK_STATS_CACHE_TTL_SECONDS))
),
bank_stats_cache_max_entries=int(
os.getenv(ENV_BANK_STATS_CACHE_MAX_ENTRIES, str(DEFAULT_BANK_STATS_CACHE_MAX_ENTRIES))
),
# Optimization flags
skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true",
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",
@@ -2028,6 +2296,14 @@ class HindsightConfig:
ENV_FILE_DELETE_AFTER_RETAIN, str(DEFAULT_FILE_DELETE_AFTER_RETAIN)
).lower()
== "true",
enable_document_export_api=os.getenv(
ENV_ENABLE_DOCUMENT_EXPORT_API, str(DEFAULT_ENABLE_DOCUMENT_EXPORT_API)
).lower()
== "true",
enable_document_import_api=os.getenv(
ENV_ENABLE_DOCUMENT_IMPORT_API, str(DEFAULT_ENABLE_DOCUMENT_IMPORT_API)
).lower()
== "true",
# Observations settings (consolidated knowledge from facts)
enable_observations=os.getenv(ENV_ENABLE_OBSERVATIONS, str(DEFAULT_ENABLE_OBSERVATIONS)).lower() == "true",
enable_auto_consolidation=os.getenv(
@@ -2038,6 +2314,12 @@ class HindsightConfig:
ENV_ENABLE_OBSERVATION_HISTORY, str(DEFAULT_ENABLE_OBSERVATION_HISTORY)
).lower()
== "true",
observation_history_max_entries=int(
os.getenv(
ENV_OBSERVATION_HISTORY_MAX_ENTRIES,
str(DEFAULT_OBSERVATION_HISTORY_MAX_ENTRIES),
)
),
enable_mental_model_history=os.getenv(
ENV_ENABLE_MENTAL_MODEL_HISTORY, str(DEFAULT_ENABLE_MENTAL_MODEL_HISTORY)
).lower()
@@ -2057,6 +2339,9 @@ class HindsightConfig:
str(DEFAULT_CONSOLIDATION_MAX_MEMORIES_PER_ROUND),
)
),
consolidation_dedup_threshold=float(
os.getenv(ENV_CONSOLIDATION_DEDUP_THRESHOLD, str(DEFAULT_CONSOLIDATION_DEDUP_THRESHOLD))
),
consolidation_llm_batch_size=int(
os.getenv(ENV_CONSOLIDATION_LLM_BATCH_SIZE, str(DEFAULT_CONSOLIDATION_LLM_BATCH_SIZE))
),
@@ -2099,6 +2384,7 @@ class HindsightConfig:
db_command_timeout=int(os.getenv(ENV_DB_COMMAND_TIMEOUT, str(DEFAULT_DB_COMMAND_TIMEOUT))),
db_acquire_timeout=int(os.getenv(ENV_DB_ACQUIRE_TIMEOUT, str(DEFAULT_DB_ACQUIRE_TIMEOUT))),
db_statement_timeout=int(os.getenv(ENV_DB_STATEMENT_TIMEOUT, str(DEFAULT_DB_STATEMENT_TIMEOUT))),
model_init_timeout=float(os.getenv(ENV_MODEL_INIT_TIMEOUT, str(DEFAULT_MODEL_INIT_TIMEOUT))),
# Worker configuration
worker_enabled=os.getenv(ENV_WORKER_ENABLED, str(DEFAULT_WORKER_ENABLED)).lower() == "true",
worker_id=os.getenv(ENV_WORKER_ID) or DEFAULT_WORKER_ID,
@@ -2183,6 +2469,22 @@ class HindsightConfig:
audit_log_retention_days=int(
os.getenv(ENV_AUDIT_LOG_RETENTION_DAYS, str(DEFAULT_AUDIT_LOG_RETENTION_DAYS))
),
# LLM request tracing configuration (static, server-level only)
llm_trace_enabled=os.getenv(ENV_LLM_TRACE_ENABLED, str(DEFAULT_LLM_TRACE_ENABLED)).lower() == "true",
llm_trace_scopes=[
s.strip() for s in os.getenv(ENV_LLM_TRACE_SCOPES, DEFAULT_LLM_TRACE_SCOPES).split(",") if s.strip()
],
llm_trace_retention_days=int(
os.getenv(ENV_LLM_TRACE_RETENTION_DAYS, str(DEFAULT_LLM_TRACE_RETENTION_DAYS))
),
llm_trace_max_chars=int(os.getenv(ENV_LLM_TRACE_MAX_CHARS, str(DEFAULT_LLM_TRACE_MAX_CHARS))),
# Background maintenance configuration (static, server-level only)
consolidation_reconcile_interval_seconds=int(
os.getenv(
ENV_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS,
str(DEFAULT_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS),
)
),
# Webhook configuration (static, server-level only)
webhook_url=os.getenv(ENV_WEBHOOK_URL) or DEFAULT_WEBHOOK_URL,
webhook_secret=os.getenv(ENV_WEBHOOK_SECRET) or DEFAULT_WEBHOOK_SECRET,
@@ -266,12 +266,20 @@ class ConfigResolver:
# Validate recall budget fields
_validate_recall_budget_updates(normalized_updates)
# Merge with existing config (JSONB || operator)
# Persist the override. Banks are created lazily (on first retain), so a
# PATCH that precedes any ingestion would otherwise UPDATE zero rows and
# silently no-op while returning 200. Ensure the bank row exists first
# (this also creates its per-bank vector indexes), then merge defensively:
# COALESCE guards against a NULL config column (NULL || jsonb is NULL),
# which would drop the override even when a row is updated.
from .engine.retain.fact_storage import ensure_bank_exists
async with self._backend.acquire() as conn:
await ensure_bank_exists(conn, bank_id, ops=self._backend.ops)
await conn.execute(
f"""
UPDATE {fq_table("banks")}
SET config = config || $1::jsonb,
SET config = COALESCE(config, '{{}}'::jsonb) || $1::jsonb,
updated_at = now()
WHERE bank_id = $2
""",
@@ -16,11 +16,59 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from pydantic import BaseModel, Field
from ..engine.db_utils import acquire_with_retry
logger = logging.getLogger(__name__)
class AuditLogEntry(BaseModel):
"""A single audit log entry."""
id: str
action: str
transport: str
bank_id: str | None
started_at: str | None
ended_at: str | None
duration_ms: int | None = Field(
default=None,
description="Server-computed duration in milliseconds (started_at → ended_at). Null if not yet completed.",
)
request: dict[str, Any] | None
response: dict[str, Any] | None
metadata: dict[str, Any]
class AuditLogListResponse(BaseModel):
"""Response model for list audit logs endpoint."""
bank_id: str
total: int
limit: int
offset: int
items: list[AuditLogEntry]
class AuditLogStatsBucket(BaseModel):
"""A single time bucket in audit log stats."""
time: str
actions: dict[str, int]
total: int
class AuditLogStatsResponse(BaseModel):
"""Response model for audit log stats endpoint."""
bank_id: str
period: str
trunc: str
start: str
buckets: list[AuditLogStatsBucket]
@dataclass
class AuditEntry:
"""A single audit log entry."""
@@ -59,11 +107,11 @@ def _safe_json(data: Any) -> str | None:
return None
_SWEEP_INTERVAL_SECONDS = 3600 # Run retention sweep every hour
class AuditLogger:
"""Fire-and-forget audit log writer with optional retention sweep."""
"""Fire-and-forget audit log writer.
Retention of old rows is handled by the background :class:`MaintenanceLoop`.
"""
def __init__(
self,
@@ -71,14 +119,11 @@ class AuditLogger:
schema_getter: Callable[[], str],
enabled: bool,
allowed_actions: list[str],
retention_days: int = -1,
) -> None:
self._pool_getter = pool_getter
self._schema_getter = schema_getter
self._enabled = enabled
self._allowed_actions: frozenset[str] | None = frozenset(allowed_actions) if allowed_actions else None
self._retention_days = retention_days
self._sweep_task: asyncio.Task | None = None
def is_enabled(self, action: str) -> bool:
"""Check if audit logging is enabled for this action."""
@@ -128,48 +173,6 @@ class AuditLogger:
except Exception as e:
logger.warning(f"Audit log write failed for action={entry.action}: {e}")
def start_retention_sweep(self) -> None:
"""Start the periodic retention sweep if retention is configured."""
if self._retention_days <= 0 or not self._enabled:
return
try:
self._sweep_task = asyncio.create_task(self._sweep_loop())
except RuntimeError:
logger.debug("Cannot start retention sweep: no running event loop")
async def stop_retention_sweep(self) -> None:
"""Stop the periodic retention sweep."""
if self._sweep_task and not self._sweep_task.done():
self._sweep_task.cancel()
try:
await self._sweep_task
except asyncio.CancelledError:
pass
self._sweep_task = None
async def _sweep_loop(self) -> None:
"""Periodically delete audit log entries older than retention_days."""
while True:
await self._run_sweep()
await asyncio.sleep(_SWEEP_INTERVAL_SECONDS)
async def _run_sweep(self) -> None:
"""Delete expired audit log entries. Concurrent-safe via row-level deletes."""
pool = self._pool_getter()
if pool is None:
return
try:
schema = self._schema_getter()
table = f"{schema}.audit_log"
async with acquire_with_retry(pool, max_retries=1) as conn:
result = await conn.execute(
f"DELETE FROM {table} WHERE started_at < NOW() - INTERVAL '{self._retention_days} days'"
)
if result and result != "DELETE 0":
logger.info(f"Audit log retention sweep: {result}")
except Exception as e:
logger.warning(f"Audit log retention sweep failed: {e}")
@asynccontextmanager
async def audit_context(
@@ -0,0 +1,122 @@
"""TTL + coalescing cache for `get_bank_stats`.
`get_bank_stats` aggregates over `memory_links` (and joins to `memory_units`),
which can be a multi-second parallel sequential scan on banks with millions of
rows. The result is intentionally approximate (it powers a UI widget and a
freshness hint inside `reflect`), so caching it for a few tens of seconds is
safe and dramatically reduces planner-driven thrash from clients that poll.
The cache also coalesces concurrent misses on the same key onto a single
in-flight task so that N concurrent callers produce one query rather than N.
"""
from __future__ import annotations
import asyncio
import time
from collections import OrderedDict
from typing import Any, Awaitable, Callable
class BankStatsCache:
"""Per-process TTL cache keyed on (schema, bank_id).
`ttl_seconds <= 0` disables caching: each call passes straight through to
the loader. `max_entries` bounds memory in environments with many banks.
"""
def __init__(self, *, ttl_seconds: float, max_entries: int) -> None:
self._ttl = float(ttl_seconds)
self._max_entries = int(max_entries) if max_entries and max_entries > 0 else 0
self._entries: OrderedDict[tuple[str, str], tuple[float, dict[str, Any]]] = OrderedDict()
self._in_flight: dict[tuple[str, str], asyncio.Future[dict[str, Any]]] = {}
self._lock = asyncio.Lock()
@property
def enabled(self) -> bool:
return self._ttl > 0
def _now(self) -> float:
return time.monotonic()
def _get_fresh_unlocked(self, key: tuple[str, str]) -> dict[str, Any] | None:
entry = self._entries.get(key)
if entry is None:
return None
expires_at, value = entry
if expires_at <= self._now():
# Expired — drop so the loader runs again.
self._entries.pop(key, None)
return None
# Mark as recently used for LRU eviction.
self._entries.move_to_end(key)
return value
def _store_unlocked(self, key: tuple[str, str], value: dict[str, Any]) -> None:
if not self.enabled:
return
self._entries[key] = (self._now() + self._ttl, value)
self._entries.move_to_end(key)
if self._max_entries:
while len(self._entries) > self._max_entries:
self._entries.popitem(last=False)
async def get_or_load(
self,
schema: str,
bank_id: str,
loader: Callable[[], Awaitable[dict[str, Any]]],
) -> dict[str, Any]:
"""Return cached stats for `(schema, bank_id)` or call `loader()`.
Concurrent misses on the same key are coalesced onto a single
in-flight loader.
"""
if not self.enabled:
return await loader()
key = (schema, bank_id)
async with self._lock:
cached = self._get_fresh_unlocked(key)
if cached is not None:
return cached
in_flight = self._in_flight.get(key)
if in_flight is None:
in_flight = asyncio.get_running_loop().create_future()
self._in_flight[key] = in_flight
is_owner = True
else:
is_owner = False
if not is_owner:
return await asyncio.shield(in_flight)
try:
value = await loader()
except BaseException as exc:
async with self._lock:
self._in_flight.pop(key, None)
if not in_flight.done():
in_flight.set_exception(exc)
# Suppress "Future exception was never retrieved" when no other
# caller was waiting on this loader — we re-raise to the owner
# immediately and the future is a no-op in that case.
in_flight.exception()
raise
async with self._lock:
self._store_unlocked(key, value)
self._in_flight.pop(key, None)
if not in_flight.done():
in_flight.set_result(value)
return value
async def invalidate(self, schema: str, bank_id: str) -> None:
"""Drop any cached stats for `(schema, bank_id)`."""
async with self._lock:
self._entries.pop((schema, bank_id), None)
async def clear(self) -> None:
async with self._lock:
self._entries.clear()
@@ -22,19 +22,30 @@ import time
import uuid
from collections import defaultdict
from contextlib import AsyncExitStack
from dataclasses import dataclass, field
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from itertools import combinations
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal
from pydantic import BaseModel, field_validator
from ...config import get_config
from ...worker.stage import set_stage
from ..db_utils import acquire_with_retry
from ..llm_trace import (
record_created_memory_ids,
record_source_memory_ids,
reset_trace_context,
set_trace_context,
trace_context_of,
)
from ..llm_wrapper import sanitize_llm_output
from ..memory_engine import Budget, fq_table
from ..retain import embedding_utils
from .prompts import build_batch_consolidation_prompt
from .prompts import (
build_consolidation_input,
build_consolidation_system_prompt,
)
if TYPE_CHECKING:
from asyncpg import Connection
@@ -46,6 +57,254 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _norm_obs_text(text: str) -> str:
"""Whitespace-normalised observation text for exact-duplicate matching.
Collapses runs of whitespace only; case is preserved. The reconciliation guard
drops a CREATE on the premise that an exact-text match loses no information — but
case-folding would also drop a create differing only in case (e.g. "TLS" vs "tls"),
which *does* lose information, so we match case-sensitively.
"""
return " ".join((text or "").split()).strip()
def _duplicate_create_target(
create_text: str,
shown_obs_by_text: "dict[str, MemoryFact]",
update_texts: set[str],
) -> str | None:
"""Return a human label for what ``create_text`` duplicates, or None if novel.
A CREATE is a duplicate when its normalised text matches an observation that was
already shown to the LLM, or the text of an UPDATE issued in the same response
(the model occasionally UPDATEs the twin to text X and also CREATEs X). Exact-text
match means no information is lost by dropping the CREATE.
"""
norm = _norm_obs_text(create_text)
matched = shown_obs_by_text.get(norm)
if matched is not None:
return f"shown observation {str(matched.id)[:8]}"
if norm in update_texts:
return "an UPDATE in this response"
return None
# Top-K existing observations probed (by the new observation's own embedding) when
# semantic dedup is enabled. Small: we only need the nearest few candidates.
_DEDUP_TOP_K = 5
class _DedupDecision(BaseModel):
"""Focused 1-by-1 verdict for whether a new observation duplicates an existing one."""
action: Literal["merge", "keep"]
text: str = "" # the synthesized merged observation (when action == "merge")
reason: str = ""
_DEDUP_PROMPT = """You reconcile long-term memory observations. A NEW observation is about to be \
stored, and it is highly similar to an EXISTING one:
[NEW] {new}
[EXISTING] {existing}
If they assert the SAME fact (wording aside), respond action="merge" and provide `text`: a single \
observation that preserves EVERY detail from both. If they differ in ANY important detail — a \
number/quantity, a named entity or language, a negation, or a condition — respond action="keep"."""
def _dedup_active(config: Any) -> bool:
"""Whether create/update semantic dedup runs for this consolidation.
Enabled when the resolved threshold is < 1.0, EXCEPT on Oracle: the merge path uses
Postgres-only SQL (``unnest``/``array_agg``, ``UPDATE ... FROM``), so on Oracle dedup is
skipped — it behaves exactly as it did before this feature, regardless of the configured
threshold. This is why the feature can ship enabled-by-default without breaking Oracle.
"""
if config is None or getattr(config, "consolidation_dedup_threshold", 1.0) >= 1.0:
return False
return get_config().database_backend != "oracle"
@dataclass
class _DedupOutcome:
"""Result of probing one observation against its in-scope neighbours.
``best_id`` is the nearest observation at/above the threshold (None if none),
``merged_text`` is the LLM-synthesized union text (set only when ``should_merge``).
"""
best_id: str | None
merged_text: str
should_merge: bool
async def _dedup_adjudicate(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
config: Any,
dedup_llm_config: Any,
anchor_text: str,
anchor_emb_str: str | None,
tags: list[str] | None,
exclude_id: str | None,
) -> _DedupOutcome:
"""Probe one observation's embedding against in-scope observations and adjudicate a merge.
Anchored on the observation text — the correct obs<->obs comparison, unlike consolidation
recall which is anchored on the raw fact. Returns the nearest observation at/above
``consolidation_dedup_threshold`` and, when found, the LLM's focused 1-by-1 merge-or-keep
verdict (scope ``consolidation_dedup``): the LLM reads both texts, so a word-level difference
(number / negation / entity) is respected. ``exclude_id`` skips the anchor observation itself
(used by the UPDATE path, where the anchor row already exists and would self-match at 1.0).
``anchor_emb_str`` reuses an already-computed embedding (the UPDATE path just embedded it);
pass None to embed ``anchor_text`` here (the CREATE path).
"""
from ..search.retrieval import retrieve_semantic_bm25_combined
threshold = config.consolidation_dedup_threshold
if anchor_emb_str is None:
embs = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [anchor_text])
if not embs:
return _DedupOutcome(best_id=None, merged_text="", should_merge=False)
anchor_emb_str = str(embs[0])
tags_match = "all_strict" if tags else "any"
grouped = await retrieve_semantic_bm25_combined(
conn, anchor_emb_str, anchor_text, bank_id, ["observation"], _DEDUP_TOP_K, tags=tags, tags_match=tags_match
)
results = grouped.get("observation", ([], []))[0]
best_id: str | None = None
best_text = ""
best_sim = threshold # only candidates at/above the threshold are considered
for r in results:
rid = str(r.id)
if exclude_id is not None and rid == exclude_id:
continue # never match the anchor observation against itself
sim = r.similarity or 0.0
if sim >= best_sim:
best_id, best_text, best_sim = rid, r.text, sim
if best_id is None:
return _DedupOutcome(best_id=None, merged_text="", should_merge=False)
decision: _DedupDecision = await dedup_llm_config.call(
messages=[{"role": "user", "content": _DEDUP_PROMPT.format(new=anchor_text, existing=best_text)}],
response_format=_DedupDecision,
scope="consolidation_dedup",
)
if decision.action != "merge":
return _DedupOutcome(best_id=best_id, merged_text="", should_merge=False)
return _DedupOutcome(best_id=best_id, merged_text=decision.text.strip() or best_text, should_merge=True)
async def _dedup_reconcile_create(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
config: Any,
dedup_llm_config: Any,
create_text: str,
create_source_ids: list[uuid.UUID],
tags: list[str] | None,
) -> str | None:
"""Semantic dedup for a single CREATE (create-time, focused 1-by-1).
On "merge", folds the new source facts + the synthesized text into the existing
observation and returns its id (caller skips the CREATE). Returns None when there is
no near twin or the LLM keeps them distinct.
"""
outcome = await _dedup_adjudicate(
conn, memory_engine, bank_id, config, dedup_llm_config, create_text, None, tags, exclude_id=None
)
if not outcome.should_merge or outcome.best_id is None:
return None
# Fold the new source facts into the twin and persist the merged text. We keep the twin's
# existing embedding: the merged text is >= threshold similar, so the stored vector stays
# representative and we avoid a re-embed + a dialect-specific vector UPDATE.
await conn.execute(
f"""
UPDATE {fq_table("memory_units")}
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),
updated_at = now()
WHERE id = $3::uuid
""",
outcome.merged_text,
create_source_ids,
uuid.UUID(outcome.best_id),
)
return outcome.best_id
async def _dedup_reconcile_update(
conn: "Connection",
memory_engine: "MemoryEngine",
bank_id: str,
config: Any,
dedup_llm_config: Any,
updated_id: str,
updated_text: str,
updated_emb_str: str | None,
tags: list[str] | None,
) -> None:
"""Semantic dedup for an UPDATE (after the observation was rewritten + re-embedded).
An UPDATE rewrites an observation's text and re-embeds it, so its vector can drift to
within threshold of a DIFFERENT existing observation. The create-time guard never sees
this (it only runs on CREATE), so without this the two persist as a near-duplicate pair —
the measured residual-duplicate source. Probe the updated observation's new embedding
against the others (excluding itself); on "merge", fold the just-updated observation's
sources into the twin, persist the merged text, and DELETE the updated row. Unlike the
CREATE path the row already exists, so reconciliation is a fold-and-delete, not a skip.
"""
outcome = await _dedup_adjudicate(
conn,
memory_engine,
bank_id,
config,
dedup_llm_config,
updated_text,
updated_emb_str,
tags,
exclude_id=updated_id,
)
if not outcome.should_merge or outcome.best_id is None:
return
# Fold the updated observation's 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).
await conn.execute(
f"""
UPDATE {fq_table("memory_units")} t
SET text = $1,
source_memory_ids = (
SELECT array_agg(DISTINCT e) FROM unnest(t.source_memory_ids || u.source_memory_ids) e
),
proof_count = (
SELECT count(DISTINCT e) FROM unnest(t.source_memory_ids || u.source_memory_ids) e
),
updated_at = now()
FROM {fq_table("memory_units")} u
WHERE t.id = $2::uuid AND u.id = $3::uuid
""",
outcome.merged_text,
uuid.UUID(outcome.best_id),
uuid.UUID(updated_id),
)
await _execute_delete_action(conn, bank_id, updated_id)
logger.info(
"[CONSOLIDATION] dedup-merged updated observation %s into %s (cosine>=%.2f)",
updated_id[:8],
outcome.best_id[:8],
config.consolidation_dedup_threshold,
)
@dataclass
class _BatchDeltas:
"""Per-LLM-batch deltas, merged into the job's running stats after dispatch.
@@ -167,6 +426,9 @@ async def _filter_live_source_memories(
class _CreateAction(BaseModel):
text: str
source_fact_ids: list[str] # memory UUIDs from the NEW FACTS list
# One-sentence justification from the LLM (why CREATE vs UPDATE). Diagnostic
# only — surfaced in the consolidation trace to explain duplicate creates.
reason: str = ""
@field_validator("text", mode="before")
@classmethod
@@ -178,6 +440,7 @@ class _UpdateAction(BaseModel):
text: str
observation_id: str # UUID of the existing observation to update
source_fact_ids: list[str] # memory UUIDs from the NEW FACTS list
reason: str = "" # LLM's one-sentence justification (diagnostic only)
@field_validator("text", mode="before")
@classmethod
@@ -187,6 +450,7 @@ class _UpdateAction(BaseModel):
class _DeleteAction(BaseModel):
observation_id: str # UUID of the observation to remove
reason: str = "" # LLM's one-sentence justification (diagnostic only)
class _ConsolidationBatchResponse(BaseModel):
@@ -361,8 +625,36 @@ async def run_consolidation_job(
# Build a configured LLM wrapper that applies per-bank settings (e.g. safety settings)
# to every call without leaking across operations.
llm_config = memory_engine._consolidation_llm_config.with_config(config)
llm_config = memory_engine._consolidation_llm_config.with_config(config, bank_id=bank_id, operation="consolidation")
# Bind the operation trace context for the whole run so the create/update DB
# sites (deep inside _process_memory_batch) can accumulate the observations
# this consolidation produced and the source memories it consumed onto the
# trace — flushed onto every trace row on exit by attach_memory_ids.
trace_ctx = trace_context_of(llm_config)
trace_token = set_trace_context(trace_ctx) if trace_ctx is not None else None
try:
return await _run_consolidation_job(
memory_engine, bank_id, request_context, config, llm_config, operation_id, observation_scopes
)
finally:
if trace_token is not None:
reset_trace_context(trace_token)
# Fire-and-forget: patched on a background task, off the consolidation
# critical path.
memory_engine._llm_recorder.attach_memory_ids(trace_ctx)
async def _run_consolidation_job(
memory_engine: "MemoryEngine",
bank_id: str,
request_context: "RequestContext",
config: Any,
llm_config: Any,
operation_id: str | None = None,
observation_scopes: list[list[str]] | None = None,
) -> dict[str, Any]:
"""Core consolidation flow. See ``run_consolidation_job`` for the public entrypoint."""
perf = ConsolidationPerfLog(bank_id)
max_memories_per_batch = config.consolidation_batch_size
max_memories_per_round = config.consolidation_max_memories_per_round
@@ -426,6 +718,44 @@ async def run_consolidation_job(
logger.info(f"[CONSOLIDATION] bank={bank_id} total_unconsolidated={total_count}")
perf.log(f"[1] Found {total_count} pending memories to consolidate")
# Initial durable progress snapshot so an operator polling the operation status
# API sees the job has started and how much work it found, before the first batch
# of LLM work completes (which can take minutes on a dense bank). Uses the same
# "consolidating" stage as the per-batch heartbeat so the operator sees a single
# phase advancing 0/N -> N/N rather than an opaque "scanning" -> "processing" hop.
set_stage("consolidation.consolidating")
await memory_engine._write_operation_progress(operation_id, stage="consolidating", processed=0, total=total_count)
async def _count_unconsolidated() -> int:
"""Re-count memories still pending consolidation in this job's scope.
``total_count`` is a point-in-time estimate from job start; memories retained
while consolidation runs get picked up by later fetches, so processed can pass
it. When that happens we re-count to report a real total (processed + remaining)
instead of pinning the bar at 100%."""
async with acquire_with_retry(pool) as count_conn:
pending = await count_conn.fetchval(
f"""
SELECT COUNT(*)
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND consolidated_at IS NULL
AND consolidation_failed_at IS NULL
AND fact_type IN ('experience', 'world')
{scope_clause}
""",
*scope_params,
)
return pending or 0
async def _progress_total(processed: int) -> int:
# Cheap path: while we're still within the start-of-job estimate it's exact, so
# no extra query. Only re-count once the estimate is exhausted (≈the final batch
# normally, or repeatedly only if memories keep arriving mid-run).
if processed < total_count:
return total_count
return processed + await _count_unconsolidated()
# Process each memory with individual commits for crash recovery
stats: dict[str, int] = {
"memories_processed": 0,
@@ -446,10 +776,18 @@ async def run_consolidation_job(
hit_round_limit = False
llm_batch_num = 0
# Cumulative count of memories processed across the whole job, shared by
# the per-batch log so it can still report processed/total under parallelism.
# Mutable container so the inner closure can update without a `nonlocal`.
cumulative_progress = {"processed": 0}
# Cumulative counters across the whole job, shared by the per-batch log and the
# durable progress snapshot so both report processed/total (and observation
# tallies) under parallelism. Mutable container so the inner closure can update
# without a `nonlocal`.
cumulative_progress = {
"processed": 0,
"observations_created": 0,
"observations_updated": 0,
"observations_merged": 0,
"observations_deleted": 0,
"memories_failed": 0,
}
while True:
# Cap fetch size by remaining round budget
fetch_limit = (
@@ -670,12 +1008,18 @@ async def run_consolidation_job(
local_stats["memories_failed"] += 1
# Maintain the cumulative-progress indicator under parallelism:
# increment a shared counter and snapshot under the same statement
# so the snapshot includes this batch. No await between the read
# and write, so single-threaded asyncio gives us atomicity for free
# no lock needed.
# increment shared counters and snapshot under the same statements so
# the snapshot includes this batch. No await between the reads and
# writes, so single-threaded asyncio gives us atomicity for free
# no lock needed.
cumulative_progress["processed"] += local_stats["memories_processed"]
cumulative_progress["observations_created"] += local_stats["observations_created"]
cumulative_progress["observations_updated"] += local_stats["observations_updated"]
cumulative_progress["observations_merged"] += local_stats["observations_merged"]
cumulative_progress["observations_deleted"] += local_stats["observations_deleted"]
cumulative_progress["memories_failed"] += local_stats["memories_failed"]
cum_processed = cumulative_progress["processed"]
cum_snapshot = dict(cumulative_progress)
# Per-batch log uses batch_perf so timings/llm-calls/tokens reflect
# only this batch's own work, even when other batches are running
@@ -703,6 +1047,27 @@ async def run_consolidation_job(
f" | avg={llm_batch_time / max(1, len(llm_batch_local)):.3f}s/memory"
)
# Durable progress snapshot per LLM batch — this is the heartbeat an
# operator polls. The whole fetched batch is processed inside one outer
# round, so a round-boundary write would sit at the pre-round count for
# the entire (often minutes-long) LLM phase; writing here advances
# processed/total as each batch commits. set_stage mirrors it for the
# live worker log.
set_stage(f"consolidation.llm_batch.{batch_num_local}")
await memory_engine._write_operation_progress(
operation_id,
stage="consolidating",
processed=cum_processed,
total=await _progress_total(cum_processed),
detail={
"observations_created": cum_snapshot["observations_created"],
"observations_updated": cum_snapshot["observations_updated"],
"observations_merged": cum_snapshot["observations_merged"],
"observations_deleted": cum_snapshot["observations_deleted"],
"memories_failed": cum_snapshot["memories_failed"],
},
)
# Fold batch counters into the job-level perf so the final summary
# (perf.flush) totals every batch correctly. Safe without a lock —
# ConsolidationPerfLog.merge_from is a series of += on Python ints
@@ -841,6 +1206,13 @@ async def run_consolidation_job(
stats["mental_models_refreshed"] = 0
logger.info(f"[CONSOLIDATION] bank={bank_id} skipping mental model refresh (round limit hit, re-queued)")
else:
set_stage("consolidation.refreshing_mental_models")
await memory_engine._write_operation_progress(
operation_id,
stage="refreshing_mental_models",
processed=stats["memories_processed"],
total=await _progress_total(stats["memories_processed"]),
)
# SECURITY: Only refresh mental models with matching tags (or all if no tags were consolidated)
mental_models_refreshed = await _trigger_mental_model_refreshes(
memory_engine=memory_engine,
@@ -984,6 +1356,9 @@ async def _process_memory_batch(
"""
import asyncio
# Map the source memories this batch consumes onto the consolidation trace.
record_source_memory_ids([str(m["id"]) for m in memories])
# 1. Parallel recalls — one per fact
# When obs_tags_override is set, use it as the observation scope for all facts.
t0 = time.time()
@@ -1063,6 +1438,20 @@ async def _process_memory_batch(
mem_by_id = {str(m["id"]): m for m in memories}
# Semantic dedup: when enabled, an observation that is >= the threshold cosine to a DIFFERENT
# existing observation is reconciled by a focused 1-by-1 LLM merge (anchored on the observation
# text, not the source fact). It runs on both CREATE (a near-dup emitted despite the twin being
# in context — weak-model failure mode) and UPDATE (a rewrite+re-embed that drifts an existing
# observation into a twin — the create-time guard can't see this). The trace operation/scope is
# "consolidation_dedup" (routes through the consolidation concurrency bucket via llm_wrapper's
# "consolidation" prefix; recorded distinctly in llm_requests).
dedup_enabled = _dedup_active(config)
dedup_llm_config = (
memory_engine._consolidation_llm_config.with_config(config, bank_id=bank_id, operation="consolidation_dedup")
if dedup_enabled
else None
)
# Execute deletes first to free observation slots before creates consume them
deleted_count = 0
for delete in llm_result.deletes:
@@ -1087,7 +1476,7 @@ async def _process_memory_batch(
)
continue
agg = _aggregate_source_fields(source_mems, tags=fact_tags)
await _execute_update_action(
updated_emb_str = await _execute_update_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
@@ -1103,17 +1492,76 @@ async def _process_memory_batch(
)
for m in source_mems:
per_memory_updated.add(str(m["id"]))
# Reconcile the rewritten observation against its neighbours: the re-embed may have
# drifted it into a near-twin of another existing observation (the residual-duplicate
# source). updated_emb_str is None when the update was skipped — nothing to reconcile.
if dedup_enabled and updated_emb_str is not None:
await _dedup_reconcile_update(
conn,
memory_engine,
bank_id,
config,
dedup_llm_config,
update.observation_id,
update.text,
updated_emb_str,
agg.tags,
)
# Deterministic dedup guard: map the observations the LLM was SHOWN by their
# normalised text. The model intermittently emits a CREATE whose text is identical
# to an observation already in its context (over-aggregation / incoherence — it even
# UPDATEs the twin and creates a sibling). When that happens we drop the duplicate
# CREATE instead of inserting a redundant row. No extra LLM/embedding cost — the
# match is exact text against the in-memory set.
shown_obs_by_text = {_norm_obs_text(o.text): o for o in union_observations}
# Also collapse a CREATE that reproduces the text of an UPDATE issued in the SAME
# response (the model occasionally UPDATEs the twin to text X and also CREATEs X).
update_texts = {_norm_obs_text(u.text) for u in llm_result.updates if u.text}
for create in llm_result.creates:
source_mems = [mem_by_id[fid] for fid in create.source_fact_ids if fid in mem_by_id]
if not source_mems:
continue
agg = _aggregate_source_fields(source_mems, tags=fact_tags)
create_source_ids = [m["id"] for m in source_mems]
# Reconcile against observations shown to the LLM: an exact-text match means
# this CREATE reproduces verbatim an observation the model already had in context.
# Since that observation already carries this exact text, drop the duplicate CREATE
# — no row is inserted, nothing is lost. We deliberately do NOT also UPDATE the twin
# here: the LLM frequently UPDATEd it earlier in this same batch, and a second update
# would run off the pre-LLM snapshot and clobber that change (see _dedupe_updates).
duplicate_of = _duplicate_create_target(create.text, shown_obs_by_text, update_texts)
if duplicate_of is not None:
logger.warning(
"[CONSOLIDATION] dropped duplicate observation CREATE — verbatim match of %s; llm_reason=%r",
duplicate_of,
create.reason or "(none given)",
)
continue
# Semantic near-duplicate reconciliation: merge this CREATE into an existing
# near-identical observation (LLM-adjudicated, 1-by-1) instead of inserting a dup.
if dedup_enabled:
merged_into = await _dedup_reconcile_create(
conn, memory_engine, bank_id, config, dedup_llm_config, create.text, create_source_ids, agg.tags
)
if merged_into is not None:
logger.info(
"[CONSOLIDATION] dedup-merged observation CREATE into %s (cosine>=%.2f)",
merged_into[:8],
config.consolidation_dedup_threshold,
)
for m in source_mems:
per_memory_created.add(str(m["id"]))
continue
await _execute_create_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
source_memory_ids=[m["id"] for m in source_mems],
source_memory_ids=create_source_ids,
text=create.text,
source_fact_tags=agg.tags,
event_date=agg.event_date,
@@ -1153,6 +1601,64 @@ def _max_date(dates: "Any") -> "datetime | None":
return max((d for d in dates if d is not None), default=None)
@dataclass(frozen=True)
class _ObservationHistorySnapshot:
"""Pre-update state of an observation, persisted as the ``content`` JSON blob
of one observation_history row.
Temporal fields are the ISO strings carried on MemoryFact; new_source_memory_ids
are the ids added by the update.
"""
previous_text: str | None
previous_tags: list[str]
previous_occurred_start: str | None
previous_occurred_end: str | None
previous_mentioned_at: str | None
new_source_memory_ids: list[str]
async def _append_observation_history(
conn: "Connection",
bank_id: str,
observation_id: str,
snapshot: _ObservationHistorySnapshot,
max_entries: int,
) -> None:
"""Insert one pre-update snapshot into ``observation_history``, then delete the
oldest rows beyond ``max_entries`` for this observation.
The snapshot is stored as a single JSONB ``content`` blob (per-row, so it stays
small). Bounding by row count keeps a frequently-reinforced observation's
history from growing without bound.
"""
obs_uuid = uuid.UUID(observation_id)
await conn.execute(
f"""
INSERT INTO {fq_table("observation_history")} (observation_id, bank_id, content, changed_at)
VALUES ($1, $2, $3::jsonb, now())
""",
obs_uuid,
bank_id,
json.dumps(asdict(snapshot)),
)
if max_entries and max_entries > 0:
await conn.execute(
f"""
DELETE FROM {fq_table("observation_history")}
WHERE observation_id = $1
AND id NOT IN (
SELECT id FROM {fq_table("observation_history")}
WHERE observation_id = $1
ORDER BY changed_at DESC, id DESC
LIMIT $2
)
""",
obs_uuid,
max_entries,
)
async def _execute_update_action(
conn: "Connection",
memory_engine: "MemoryEngine",
@@ -1166,12 +1672,15 @@ async def _execute_update_action(
source_occurred_end: datetime | None = None,
source_mentioned_at: datetime | None = None,
perf: ConsolidationPerfLog | None = None,
) -> 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.
Returns the observation's freshly-computed embedding (pgvector literal) so the caller can
run UPDATE-path dedup without re-embedding, or None when the update was skipped.
"""
model = next((m for m in observations if str(m.id) == observation_id), None)
if not model:
@@ -1189,15 +1698,14 @@ async def _execute_update_action(
from ...config import get_config
history_entry = {
"previous_text": model.text,
"previous_tags": list(model.tags or []),
"previous_occurred_start": model.occurred_start,
"previous_occurred_end": model.occurred_end,
"previous_mentioned_at": model.mentioned_at,
"changed_at": datetime.now(timezone.utc).isoformat(),
"new_source_memory_ids": [str(mid) for mid in source_memory_ids],
}
history_entry = _ObservationHistorySnapshot(
previous_text=model.text,
previous_tags=list(model.tags or []),
previous_occurred_start=model.occurred_start,
previous_occurred_end=model.occurred_end,
previous_mentioned_at=model.mentioned_at,
new_source_memory_ids=[str(mid) for mid in source_memory_ids],
)
source_ids = list(model.source_fact_ids or []) + source_memory_ids
@@ -1213,9 +1721,6 @@ async def _execute_update_action(
perf.record_timing("embedding", time.time() - t0)
config = get_config()
history_clause = (
"history = COALESCE(history, '[]'::jsonb) || $3::jsonb," if config.enable_observation_history else ""
)
t0 = time.time()
await conn.execute(
@@ -1223,19 +1728,17 @@ async def _execute_update_action(
UPDATE {fq_table("memory_units")}
SET text = $1,
embedding = $2::vector,
{history_clause}
source_memory_ids = $4,
proof_count = $5,
tags = $10,
source_memory_ids = $3,
proof_count = $4,
tags = $9,
updated_at = now(),
occurred_start = LEAST(occurred_start, COALESCE($7, occurred_start)),
occurred_end = GREATEST(occurred_end, COALESCE($8, occurred_end)),
mentioned_at = GREATEST(mentioned_at, COALESCE($9, mentioned_at))
WHERE id = $6
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))
WHERE id = $5
""",
new_text,
embedding_str,
json.dumps([history_entry]),
source_ids,
len(source_ids),
uuid.UUID(observation_id),
@@ -1245,6 +1748,15 @@ async def _execute_update_action(
merged_tags,
)
# Record the pre-update snapshot in the dedicated observation_history table
# (one row per change), then trim to the configured cap. History lived in a
# single unbounded JSONB column before; an often-reinforced observation grew
# it until it crossed Postgres's 256MB jsonb limit and got stuck.
if config.enable_observation_history:
await _append_observation_history(
conn, bank_id, observation_id, history_entry, config.observation_history_max_entries
)
# Sync observation_sources junction table (Oracle only — PG uses native array ops).
if memory_engine._backend.ops.uses_observation_sources_table:
obs_uuid = uuid.UUID(observation_id)
@@ -1265,7 +1777,10 @@ async def _execute_update_action(
if perf:
perf.record_timing("db_write", time.time() - t0)
# Map the updated observation onto the consolidation trace as a produced memory.
record_created_memory_ids([observation_id])
logger.debug(f"Updated observation {observation_id} from {len(source_memory_ids)} source memories")
return embedding_str
async def _execute_create_action(
@@ -1287,7 +1802,7 @@ async def _execute_create_action(
Tags are inherited from the source facts (determined algorithmically, not by LLM)
to maintain visibility scope.
"""
await _create_observation_directly(
created = await _create_observation_directly(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
@@ -1300,6 +1815,10 @@ async def _execute_create_action(
mentioned_at=mentioned_at,
perf=perf,
)
# Map the new observation onto the consolidation trace as a produced memory.
new_id = created.get("observation_id")
if new_id:
record_created_memory_ids([new_id])
logger.debug(f"Created observation from {len(source_memory_ids)} source memories")
@@ -1398,6 +1917,13 @@ async def _find_related_observations(
include_source_facts=True, # Embed source facts so we avoid a separate DB fetch
max_source_facts_tokens=config.consolidation_source_facts_max_tokens,
max_source_facts_tokens_per_observation=config.consolidation_source_facts_max_tokens_per_observation,
# Round-robin interleave fusion (no cross-encoder): consolidation is looking
# for an existing near-identical observation to merge into. Both the
# cross-encoder (semantic #1 -> reranked #37) and RRF (semantic #1 -> outside
# the 512-token budget) were measured to bury that twin; interleave guarantees
# each retrieval arm's top hits a slot, so the semantic-#1 twin is always shown
# to the LLM, which then UPDATEs instead of creating a duplicate.
reranking="interleave",
_quiet=True, # Suppress logging
)
finally:
@@ -1532,16 +2058,38 @@ async def _consolidate_batch_with_llm(
f"(out of {max_observations_per_scope}). Prefer UPDATE over CREATE when possible."
)
prompt_template = build_batch_consolidation_prompt(
config.observations_mission,
observation_capacity_note,
# Split the prompt: a bank-agnostic system instruction (rules + input format +
# decision guide + output format) that is byte-identical across batches AND
# across banks, and a per-batch user message (mission + capacity note + facts +
# existing observations). The split lets the system prefix be served from a
# single Gemini context cache shared by every bank — the bank mission, capacity
# note, and response_schema (all bank/batch-variable) are kept OUT of the
# cached prefix so one cache serves all and it never busts within a run.
system_prompt = build_consolidation_system_prompt(
llm_output_language=getattr(config, "llm_output_language", None),
)
prompt = prompt_template.format(
user_content = build_consolidation_input(
facts_text=facts_lines,
observations_text=observations_text,
observations_mission=config.observations_mission,
observation_capacity_note=observation_capacity_note,
)
# Opt into context caching of the stable system prefix when the provider
# supports it (gemini/vertexai with the flag on). response_schema is NOT
# passed to the fingerprint: it varies per batch (max_creates) but is not
# part of the cached prefix, so keying on it would needlessly bust the cache.
cached_prefix_name: str | None = None
provider_impl = getattr(llm_config, "_provider_impl", None)
if provider_impl is not None and provider_impl.supports_prompt_caching():
try:
cached_prefix_name = await provider_impl.get_or_create_cached_prefix(
system_instruction=system_prompt,
)
except Exception:
logger.exception("Consolidation cache prefix lookup failed; falling back to uncached call")
cached_prefix_name = None
# Use a constrained response model when observation limit is active
response_model = _build_response_model(max_creates=remaining_observation_slots)
@@ -1561,12 +2109,17 @@ async def _consolidate_batch_with_llm(
for attempt in range(1, max_attempts + 1):
try:
call_kwargs: dict[str, Any] = {
"messages": [{"role": "user", "content": prompt}],
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
],
"response_format": response_model,
"scope": "consolidation",
}
if inner_max_retries is not None:
call_kwargs["max_retries"] = inner_max_retries
if cached_prefix_name is not None:
call_kwargs["cached_prefix"] = cached_prefix_name
response: _ConsolidationBatchResponse = await llm_config.call(**call_kwargs)
# Defensive truncation: some LLM providers may not enforce JSON schema max_length
creates = response.creates
@@ -1583,7 +2136,7 @@ async def _consolidate_batch_with_llm(
updates=updates,
deletes=response.deletes,
obs_count=len(union_observations),
prompt_chars=len(prompt),
prompt_chars=len(system_prompt) + len(user_content),
)
except Exception as exc:
last_exc = exc
@@ -1595,7 +2148,9 @@ async def _consolidate_batch_with_llm(
f"[CONSOLIDATION] LLM batch call failed after {max_attempts} attempts for {batch_label}, "
f"skipping batch. Last error: {last_exc}"
)
return _BatchLLMResult(obs_count=len(union_observations), prompt_chars=len(prompt), failed=True)
return _BatchLLMResult(
obs_count=len(union_observations), prompt_chars=len(system_prompt) + len(user_content), failed=True
)
async def _create_observation_directly(
@@ -1642,10 +2197,10 @@ async def _create_observation_directly(
# VectorChord: manually tokenize and insert search_vector
query = f"""
INSERT INTO {fq_table("memory_units")} (
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids, history,
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids,
tags, event_date, occurred_start, occurred_end, mentioned_at, search_vector
)
VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, '[]'::jsonb, $6, $7, $8, $9, $10,
VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, $6, $7, $8, $9, $10,
tokenize($3, 'llmlingua2')::bm25_catalog.bm25vector)
RETURNING id
"""
@@ -1661,10 +2216,10 @@ async def _create_observation_directly(
# re-ingested. Tracking a separate fix for that gap.
query = f"""
INSERT INTO {fq_table("memory_units")} (
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids, history,
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids,
tags, event_date, occurred_start, occurred_end, mentioned_at
)
VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, '[]'::jsonb, $6, $7, $8, $9, $10)
VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, $6, $7, $8, $9, $10)
RETURNING id
"""
@@ -37,6 +37,33 @@ _PROCESSING_RULES = """## PROCESSING RULES
9. KEEP DISTINCT TOPICS DISTINCT: do not merge observations about different people, entities, or unrelated topics. Merging is for the same canonical fact recurring — not for related-but-distinct claims."""
# Stable description of the input shape. For the cached split path this lives in
# the system prefix (build_consolidation_system_prompt) so it is not re-sent on
# every batch; the per-batch user message then carries only the actual data.
_INPUT_FORMAT_NOTE = """## INPUT FORMAT
Each request provides new facts and existing observations:
- New facts: one per line, each prefixed with its `[uuid]`, followed by the fact text and optional temporal fields.
- Existing observations: a JSON array pooled from recalls across the new facts. Each entry has:
- `id`: unique identifier — copy this exactly when issuing an UPDATE or DELETE
- `text`: the observation content
- `proof_count`: number of supporting memories
- `occurred_start` / `occurred_end`: temporal range of source facts
- `source_memories`: array of supporting facts with their text and dates"""
# Per-batch data section for the cached split path — the stable format
# explanation above is omitted here (it lives in the cached prefix); only the
# variable facts/observations remain. Placeholders substituted at call time.
_SPLIT_INPUT_SECTION = """## INPUT
### New facts
{facts_text}
### Existing observations
{observations_text}"""
# Data section — format placeholders {facts_text} and {observations_text} are substituted at call time
_INPUT_SECTION = """## INPUT
@@ -65,7 +92,7 @@ _DECISION_GUIDE = """## DECISION GUIDE
# Output format — JSON braces escaped as {{ }} so .format() leaves them literal
_OUTPUT_SECTION = """## OUTPUT FORMAT
Return a JSON object with three arrays: `creates`, `updates`, `deletes`.
Return a JSON object with three arrays: `creates`, `updates`, `deletes`. Every entry must include a `reason`.
### Example 1 — Merging recurring claims into an existing observation
@@ -79,7 +106,7 @@ Existing observation:
Expected output (one UPDATE, no creates — both new facts are additional evidence for the same canonical decision):
{{"creates": [],
"updates": [{{"text": "Donald named Athena's sovereignty as a foundational principle of the Janus architecture.", "observation_id": "11111111-1111-1111-1111-111111111111", "source_fact_ids": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "b2c3d4e5-f6a7-8901-bcde-f12345678901"]}}],
"updates": [{{"text": "Donald named Athena's sovereignty as a foundational principle of the Janus architecture.", "observation_id": "11111111-1111-1111-1111-111111111111", "source_fact_ids": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "b2c3d4e5-f6a7-8901-bcde-f12345678901"], "reason": "Both new facts restate the same sovereignty decision already captured by obs 1111 — merged as evidence rather than creating siblings."}}],
"deletes": []}}
### Example 2 — State change updates one observation; unrelated fact creates a new one
@@ -93,8 +120,8 @@ Existing observation:
Expected output (UPDATE for the state change; CREATE for the unrelated work-hours facet):
{{"creates": [{{"text": "Alice works long hours, often past midnight.", "source_fact_ids": ["d4e5f6a7-b8c9-0123-defa-234567890123"]}}],
"updates": [{{"text": "Alice owned a 2019 Honda Civic; sold it on March 15, 2025.", "observation_id": "22222222-2222-2222-2222-222222222222", "source_fact_ids": ["c3d4e5f6-a7b8-9012-cdef-123456789012"]}}],
{{"creates": [{{"text": "Alice works long hours, often past midnight.", "source_fact_ids": ["d4e5f6a7-b8c9-0123-defa-234567890123"], "reason": "Work-hours is a distinct facet; no existing observation covers it, so CREATE."}}],
"updates": [{{"text": "Alice owned a 2019 Honda Civic; sold it on March 15, 2025.", "observation_id": "22222222-2222-2222-2222-222222222222", "source_fact_ids": ["c3d4e5f6-a7b8-9012-cdef-123456789012"], "reason": "State change to the existing Honda Civic observation 2222 — UPDATE, not a new sibling."}}],
"deletes": []}}
### Observation text rules
@@ -110,6 +137,7 @@ Expected output (UPDATE for the state change; CREATE for the unrelated work-hour
- One create or update may reference multiple facts when they jointly support the observation.
- **AT MOST ONE UPDATE PER `observation_id`**: if several new facts all update the same existing observation, emit a single `updates` entry that lists all contributing `source_fact_ids` and a single consolidated `text`. Never emit two `updates` entries with the same `observation_id` in one response — they would silently overwrite each other.
- `deletes`: only when an observation is directly superseded or contradicted by new facts.
- `reason`: REQUIRED on every create/update/delete — one sentence explaining the choice. For a CREATE, state which existing observation(s) you considered and why none matched (a near-identical existing observation means you should UPDATE, not CREATE). This is audited to catch duplicate creates.
- Do NOT include `tags` — handled automatically.
- Return `{{"creates": [], "updates": [], "deletes": []}}` if nothing durable is found."""
@@ -145,3 +173,55 @@ def build_batch_consolidation_prompt(
f"{_DECISION_GUIDE}\n\n"
f"{_OUTPUT_SECTION}" + output_language_directive(llm_output_language)
)
def build_consolidation_system_prompt(
llm_output_language: str | None = None,
) -> str:
"""Bank-agnostic, cacheable system instruction for batch consolidation.
Holds only what is constant across banks: processing rules, input format,
decision guide, and output format. The bank's MISSION is deliberately NOT
here — baking it in would make the prefix bank-specific and force a separate
Gemini context cache per mission. The mission, the per-batch INPUT, and any
capacity constraint all ride in the user message (see
:func:`build_consolidation_input`), so this prefix is identical for every
bank and a single CachedContent serves them all. Returns final text
(brace-escaped examples already unescaped) for verbatim use as system message
and cached prefix.
"""
template = (
"You are a memory consolidation system. Synthesize new facts into "
"observations, merging with existing observations when appropriate.\n\n"
f"{_MISSION_PRIORITY_NOTE}\n\n"
f"{_PROCESSING_RULES}\n\n"
f"{_INPUT_FORMAT_NOTE}\n\n"
f"{_DECISION_GUIDE}\n\n"
f"{_OUTPUT_SECTION}" + output_language_directive(llm_output_language)
)
# No {facts_text}/{observations_text} placeholders here — the only braces are
# the doubled {{ }} in the OUTPUT examples, which .format() unescapes.
return template.format()
def build_consolidation_input(
facts_text: str,
observations_text: str,
observations_mission: str | None = None,
observation_capacity_note: str | None = None,
) -> str:
"""Per-batch user message: MISSION + INPUT data + any capacity constraint.
The MISSION lives here (not in the cached system prefix) so the prefix stays
bank-agnostic and one CachedContent serves every bank. The capacity note also
lives here since it varies as observation slots fill.
"""
mission = escape_for_prompt(observations_mission or _DEFAULT_MISSION)
mission_section = f"## MISSION\n\n{mission}\n\n"
capacity_section = ""
if observation_capacity_note:
capacity_section = f"## CAPACITY CONSTRAINT\n\n{escape_for_prompt(observation_capacity_note)}\n\n"
# _SPLIT_INPUT_SECTION omits the stable observation-format explanation (now in
# the cached system prefix) — only the variable facts/observations remain.
template = mission_section + capacity_section + _SPLIT_INPUT_SECTION
return template.format(facts_text=facts_text, observations_text=observations_text)
@@ -46,7 +46,6 @@ from ..config import (
ENV_RERANKER_FLASHRANK_CPU_MEM_ARENA,
ENV_RERANKER_FLASHRANK_MODEL,
ENV_RERANKER_GOOGLE_PROJECT_ID,
ENV_RERANKER_LITELLM_SDK_API_KEY,
ENV_RERANKER_LOCAL_FORCE_CPU,
ENV_RERANKER_LOCAL_MAX_CONCURRENT,
ENV_RERANKER_LOCAL_MODEL,
@@ -1199,7 +1198,7 @@ class LiteLLMSDKCrossEncoder(CrossEncoderModel):
def __init__(
self,
api_key: str,
api_key: str | None = None,
model: str = DEFAULT_RERANKER_LITELLM_SDK_MODEL,
api_base: str | None = None,
timeout: float = 60.0,
@@ -1209,7 +1208,8 @@ class LiteLLMSDKCrossEncoder(CrossEncoderModel):
Initialize LiteLLM SDK cross-encoder client.
Args:
api_key: API key for the reranking provider
api_key: API key for the reranking provider (optional — omit for
providers that use ambient credentials, e.g. AWS Bedrock with IAM)
model: Model name with provider prefix (e.g., "deepinfra/Qwen3-reranker-8B")
api_base: Custom base URL for API (optional)
timeout: Request timeout in seconds (default: 60.0)
@@ -1284,8 +1284,9 @@ class LiteLLMSDKCrossEncoder(CrossEncoderModel):
"model": self.model,
"query": query,
"documents": texts,
"api_key": self.api_key,
}
if self.api_key:
rerank_kwargs["api_key"] = self.api_key
if self.api_base:
rerank_kwargs["api_base"] = self.api_base
@@ -1697,13 +1698,8 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
timeout=config.reranker_litellm_timeout,
)
elif provider == "litellm-sdk":
api_key = config.reranker_litellm_sdk_api_key
if not api_key:
raise ValueError(
f"{ENV_RERANKER_LITELLM_SDK_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'litellm-sdk'"
)
return LiteLLMSDKCrossEncoder(
api_key=api_key,
api_key=config.reranker_litellm_sdk_api_key or None,
model=config.reranker_litellm_sdk_model,
api_base=config.reranker_litellm_sdk_api_base,
max_tokens_per_doc=config.reranker_litellm_max_tokens_per_doc,
@@ -72,6 +72,30 @@ class DataAccessOps(ABC):
"""
...
@abstractmethod
async def lock_document_for_write(
self,
conn: DatabaseConnection,
table: str,
doc_id: str,
bank_id: str,
) -> str | None:
"""Ensure the document row exists, take a row lock on it, and return its
pre-existing ``content_hash``.
This serializes all concurrent writers for ``doc_id`` at the DB level
(so interleaved same-document retains can't corrupt each other), while
creating the row on first write. The returned hash is ``'__pending__'``
for a freshly inserted row, the stored hash for an existing one, or
``None`` if the row could not be read back.
PG does this in a single statement (``INSERT ... ON CONFLICT DO UPDATE
... RETURNING``), which always takes the row lock as part of the upsert.
Oracle can't (``MERGE`` doesn't support ``RETURNING``), so it splits the
work into an idempotent insert plus a ``SELECT ... FOR UPDATE``.
"""
...
@abstractmethod
async def insert_facts_batch(
self,
@@ -47,6 +47,37 @@ class OracleOps(DataAccessOps):
column_types=["text[]", "text[]", "text[]", "text[]", "integer[]", "text[]"],
)
async def lock_document_for_write(
self,
conn: DatabaseConnection,
table: str,
doc_id: str,
bank_id: str,
) -> str | None:
# Oracle can't express the PG "INSERT ... ON CONFLICT DO UPDATE ...
# RETURNING" upsert in one statement — MERGE doesn't support RETURNING,
# so the single-statement form rewrites to a MERGE that returns no rows
# (DPY-1003). Split it into two statements instead:
# 1. Idempotent insert that silently skips an existing row. The
# IGNORE_ROW_ON_DUPKEY_INDEX hint suppresses ORA-00001 server-side;
# a concurrent uncommitted insert of the same key blocks here until
# the other writer commits, so writers still serialize.
# 2. SELECT ... FOR UPDATE to take the row lock and read the hash
# ('__pending__' for a row we just inserted, the stored hash for an
# existing one).
await conn.execute(
f"INSERT /*+ IGNORE_ROW_ON_DUPKEY_INDEX({table}, pk_documents) */ "
f"INTO {table} (id, bank_id, original_text, content_hash) "
f"VALUES ($1, $2, '', '__pending__')",
doc_id,
bank_id,
)
return await conn.fetchval(
f"SELECT content_hash FROM {table} WHERE id = $1 AND bank_id = $2 FOR UPDATE",
doc_id,
bank_id,
)
async def insert_facts_batch(
self,
conn: DatabaseConnection,
@@ -49,6 +49,30 @@ class PostgreSQLOps(DataAccessOps):
content_hashes,
)
async def lock_document_for_write(
self,
conn: DatabaseConnection,
table: str,
doc_id: str,
bank_id: str,
) -> str | None:
# Single upsert that both creates the row (if absent) and locks it (if
# present) atomically. ON CONFLICT DO UPDATE always takes the row lock as
# part of the statement, so all concurrent same-document writers serialize
# on the document row in one consistent step (the earlier two-step form —
# DO NOTHING + a separate SELECT FOR UPDATE — could deadlock because
# DO NOTHING takes no lock on an existing row). The SET is a no-op
# self-assignment used only to acquire the lock; RETURNING yields the
# pre-existing hash (or '__pending__' for a freshly inserted row).
return await conn.fetchval(
f"INSERT INTO {table} (id, bank_id, original_text, content_hash) "
f"VALUES ($1, $2, '', '__pending__') "
f"ON CONFLICT (id, bank_id) DO UPDATE SET content_hash = {table}.content_hash "
f"RETURNING content_hash",
doc_id,
bank_id,
)
async def insert_facts_batch(
self,
conn: DatabaseConnection,
@@ -14,6 +14,7 @@ Supports multi-tenant schema isolation via ALTER SESSION SET CURRENT_SCHEMA.
"""
import datetime
import inspect
import json
import logging
import re
@@ -155,6 +156,23 @@ _JSON_COL_NAMES = {
"task_payload",
"history",
}
# NOTE: the history tables' JSON payload column is named ``content`` — deliberately
# NOT added here, because ``mental_models.content`` is plain text (adding "content"
# would corrupt those reads). The history read paths json.loads ``content`` directly.
# Columns backed by CLOB in Oracle (large text or JSON). When such a column is
# returned via a ``RETURNING`` clause it must be bound as DB_TYPE_CLOB; binding
# it as VARCHAR raises ORA-22835 ("buffer too small for CLOB to CHAR") once the
# value exceeds 4000 bytes. Union of the JSON-CLOB columns above and the
# large-text CLOB columns.
_CLOB_RETURNING_COLS = _JSON_COL_NAMES | {
"content",
"text",
"context",
"structured_content",
"text_signals",
"search_vector",
}
def _is_uuid_column(col: str) -> bool:
@@ -685,6 +703,11 @@ class OracleConnection(DatabaseConnection):
params[f"ret_{i}"] = cursor.var(oracledb.DB_TYPE_TIMESTAMP_TZ, arraysize=1)
elif clean in _NUMERIC_COLS:
params[f"ret_{i}"] = cursor.var(oracledb.DB_TYPE_NUMBER, arraysize=1)
elif clean in _CLOB_RETURNING_COLS:
# CLOB-backed column: a VARCHAR out-bind caps at 4000 bytes and
# raises ORA-22835 for larger values. Read back as a LOB in
# _read_returning_values.
params[f"ret_{i}"] = cursor.var(oracledb.DB_TYPE_CLOB, arraysize=1)
else:
params[f"ret_{i}"] = cursor.var(oracledb.DB_TYPE_VARCHAR, arraysize=1)
@@ -862,7 +885,7 @@ class OracleConnection(DatabaseConnection):
return query, params
def _read_returning_values(self, returning_cols: list[str], params: dict[str, Any]) -> dict[str, Any] | None:
async def _read_returning_values(self, returning_cols: list[str], params: dict[str, Any]) -> dict[str, Any] | None:
"""Read values from RETURNING INTO output variables after execute."""
row: dict[str, Any] = {}
for i, col in enumerate(returning_cols):
@@ -872,6 +895,14 @@ class OracleConnection(DatabaseConnection):
return None
val = values[0] if isinstance(values, list) else values
# CLOB-bound columns return a LOB handle; read it to a string. The
# async pool yields AsyncLOB whose read() is a coroutine.
if val is not None and not isinstance(val, (str, bytes, int, float)) and hasattr(val, "read"):
data = val.read()
if inspect.isawaitable(data):
data = await data
val = data
# Clean alias: "LOWER(canonical_name) AS name_lower" → "name_lower"
clean_col = col.strip()
upper = clean_col.upper()
@@ -1059,7 +1090,7 @@ class OracleConnection(DatabaseConnection):
raise
if ret_cols is not None:
row_dict = self._read_returning_values(ret_cols, params)
row_dict = await self._read_returning_values(ret_cols, params)
return [ResultRow(row_dict)] if row_dict else []
columns = [col[0].lower() for col in cursor.description or []]
@@ -1097,7 +1128,7 @@ class OracleConnection(DatabaseConnection):
raise
if ret_cols is not None:
row_dict = self._read_returning_values(ret_cols, params)
row_dict = await self._read_returning_values(ret_cols, params)
return ResultRow(row_dict) if row_dict else None
columns = [col[0].lower() for col in cursor.description or []]
@@ -1130,7 +1161,7 @@ class OracleConnection(DatabaseConnection):
await cursor.execute(query, params)
if ret_cols is not None:
row_dict = self._read_returning_values(ret_cols, params)
row_dict = await self._read_returning_values(ret_cols, params)
if row_dict is None:
return None
vals = list(row_dict.values())
@@ -43,6 +43,10 @@ from ..config import (
ENV_EMBEDDINGS_LOCAL_FORCE_CPU,
ENV_EMBEDDINGS_LOCAL_MODEL,
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
ENV_EMBEDDINGS_ONNX_DIMENSIONS,
ENV_EMBEDDINGS_ONNX_MODEL_ID,
ENV_EMBEDDINGS_ONNX_MODEL_PATH,
ENV_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH,
ENV_EMBEDDINGS_OPENAI_API_KEY,
ENV_EMBEDDINGS_OPENAI_BASE_URL,
ENV_EMBEDDINGS_OPENAI_MODEL,
@@ -252,6 +256,172 @@ class LocalSTEmbeddings(Embeddings):
return [emb.tolist() for emb in embeddings]
class OnnxEmbeddings(Embeddings):
"""Local ONNX Runtime embeddings provider.
This provider runs transformer embedding models in-process with ONNX Runtime,
avoiding a sidecar Ollama/TEI server or a remote embeddings API. It supports
sentence-transformer style mean pooling and E5-style asymmetric prefixes.
"""
def __init__(
self,
model_id: str,
model_path: str | None = None,
tokenizer_name_or_path: str | None = None,
onnx_file: str = "onnx/model.onnx",
dimensions: int | None = None,
max_tokens: int = 512,
pooling: str = "mean",
normalize: bool = True,
query_prefix: str = "query: ",
passage_prefix: str = "passage: ",
output_name: str | None = None,
):
self.model_id = model_id
self.model_path = model_path
if model_path and tokenizer_name_or_path is None:
logger.warning(
"Embeddings: ONNX model_path is set without tokenizer_name_or_path; "
"falling back to tokenizer from model_id %s. Set "
"HINDSIGHT_API_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH when using local ONNX artifacts.",
model_id,
)
self.tokenizer_name_or_path = tokenizer_name_or_path or model_id
self.onnx_file = onnx_file
self.configured_dimensions = dimensions
self.max_tokens = max_tokens
self.pooling = pooling.lower()
if self.pooling not in {"mean", "cls"}:
raise ValueError("ONNX embeddings pooling must be 'mean' or 'cls'")
self.normalize = normalize
self.query_prefix = query_prefix
self.passage_prefix = passage_prefix
self.output_name = output_name
self._session = None
self._tokenizer = None
self._dimension: int | None = dimensions
@property
def provider_name(self) -> str:
return "onnx"
@property
def dimension(self) -> int:
if self._dimension is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
return self._dimension
async def initialize(self) -> None:
if self._session is not None and self._tokenizer is not None:
return
try:
import onnxruntime as ort
from transformers import AutoTokenizer
except ImportError as exc:
raise ImportError(
"onnxruntime and transformers are required for OnnxEmbeddings. "
"Install with: pip install 'hindsight-api-slim[local-onnx]'"
) from exc
model_path = self.model_path
if not model_path:
try:
from huggingface_hub import snapshot_download
except ImportError as exc:
raise ImportError(
"huggingface-hub is required to download ONNX embedding models. "
"Set HINDSIGHT_API_EMBEDDINGS_ONNX_MODEL_PATH or install local-onnx."
) from exc
# Some large ONNX exports, for example BAAI/bge-m3, store weights in
# an external sidecar file next to model.onnx. Download both the
# requested graph and its conventional *_data sidecar when present.
snapshot_dir = snapshot_download(
repo_id=self.model_id,
allow_patterns=[self.onnx_file, f"{self.onnx_file}_data"],
)
model_path = os.path.join(snapshot_dir, self.onnx_file)
logger.info(
"Embeddings: initializing ONNX provider with model %s (%s)",
self.model_id,
model_path,
)
logger.info(
"Embeddings: ONNX query_prefix=%r passage_prefix=%r pooling=%s normalize=%s",
self.query_prefix,
self.passage_prefix,
self.pooling,
self.normalize,
)
self._tokenizer = AutoTokenizer.from_pretrained(self.tokenizer_name_or_path)
self._session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
detected = len(self.encode(["test"])[0])
if self.configured_dimensions is not None and detected != self.configured_dimensions:
raise ValueError(
f"Configured ONNX embedding dimension {self.configured_dimensions} does not match model output {detected}"
)
self._dimension = detected
logger.info("Embeddings: ONNX provider initialized (dim: %s)", self._dimension)
def _encode_prefixed(self, texts: list[str], prefix: str) -> list[list[float]]:
if prefix:
return self.encode([f"{prefix}{text}" for text in texts])
return self.encode(texts)
def encode_query(self, texts: list[str]) -> list[list[float]]:
return self._encode_prefixed(texts, self.query_prefix)
def encode_documents(self, texts: list[str]) -> list[list[float]]:
return self._encode_prefixed(texts, self.passage_prefix)
def encode(self, texts: list[str]) -> list[list[float]]:
if self._session is None or self._tokenizer is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
if not texts:
return []
import numpy as np
encoded = self._tokenizer(
texts,
padding=True,
truncation=True,
max_length=self.max_tokens,
return_tensors="np",
)
input_names = {inp.name for inp in self._session.get_inputs()}
ort_inputs = {name: value for name, value in encoded.items() if name in input_names}
if "token_type_ids" in input_names and "token_type_ids" not in ort_inputs:
ort_inputs["token_type_ids"] = np.zeros_like(encoded["input_ids"])
outputs = self._session.run([self.output_name] if self.output_name else None, ort_inputs)
token_embeddings = outputs[0]
# Some exported models expose a pooled 2-D embedding as their first output.
if getattr(token_embeddings, "ndim", 0) == 2:
embeddings = token_embeddings
elif self.pooling == "cls":
embeddings = token_embeddings[:, 0]
else:
attention_mask = encoded.get("attention_mask")
if attention_mask is None:
attention_mask = np.ones(token_embeddings.shape[:2], dtype=np.float32)
mask = attention_mask[..., None].astype(np.float32)
summed = (token_embeddings * mask).sum(axis=1)
counts = np.clip(mask.sum(axis=1), a_min=1e-9, a_max=None)
embeddings = summed / counts
if self.normalize:
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
norms[norms == 0] = 1
embeddings = embeddings / norms
return embeddings.astype(float).tolist()
class RemoteTEIEmbeddings(Embeddings):
"""
Remote embeddings implementation using HuggingFace Text Embeddings Inference (TEI) HTTP API.
@@ -1391,6 +1561,20 @@ def create_embeddings_from_env() -> Embeddings:
force_cpu=config.embeddings_local_force_cpu,
trust_remote_code=config.embeddings_local_trust_remote_code,
)
elif provider == "onnx":
return OnnxEmbeddings(
model_id=config.embeddings_onnx_model_id,
model_path=config.embeddings_onnx_model_path,
tokenizer_name_or_path=config.embeddings_onnx_tokenizer_name_or_path,
onnx_file=config.embeddings_onnx_file,
dimensions=config.embeddings_onnx_dimensions,
max_tokens=config.embeddings_onnx_max_tokens,
pooling=config.embeddings_onnx_pooling,
normalize=config.embeddings_onnx_normalize,
query_prefix=config.embeddings_onnx_query_prefix,
passage_prefix=config.embeddings_onnx_passage_prefix,
output_name=config.embeddings_onnx_output_name,
)
elif provider == "openai":
# Use dedicated embeddings API key, or fall back to LLM API key
api_key = os.environ.get(ENV_EMBEDDINGS_OPENAI_API_KEY) or os.environ.get(ENV_LLM_API_KEY)
@@ -1492,6 +1676,6 @@ def create_embeddings_from_env() -> Embeddings:
else:
raise ValueError(
f"Unknown embeddings provider: {provider}. "
f"Supported: 'local', 'tei', 'openai', 'openai-codex', 'openrouter', 'cohere', 'google', "
f"Supported: 'local', 'onnx', 'tei', 'openai', 'openai-codex', 'openrouter', 'cohere', 'google', "
f"'zeroentropy', 'litellm', 'litellm-sdk'"
)
@@ -458,8 +458,28 @@ class MemoryEngineInterface(ABC):
request_context: Request context for authentication.
Returns:
Dict with node_counts, link_counts, link_counts_by_fact_type,
link_breakdown, and operations stats.
Dict with node_counts, link_counts, link_counts_by_fact_type
(deprecated, returns empty), link_breakdown (deprecated, returns
empty), and operations stats.
"""
...
@abstractmethod
async def get_bank_freshness(
self,
bank_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Get consolidation freshness for a bank.
Cheap alternative to get_bank_stats when callers only need
last_consolidated_at / pending_consolidation / failed_consolidation.
Returns:
Dict with last_consolidated_at (ISO-8601 string or None),
pending_consolidation (int), and failed_consolidation (int).
"""
...
@@ -69,6 +69,7 @@ class LLMInterface(ABC):
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
cached_prefix: str | None = None,
) -> Any:
"""
Make an LLM API call with retry logic.
@@ -83,8 +84,13 @@ class LLMInterface(ABC):
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
skip_validation: Return raw JSON without Pydantic validation.
strict_schema: Use strict JSON schema enforcement (OpenAI only).
strict_schema: Grammar-enforce structured output via json_schema strict
(OpenAI-compatible, LiteLLM) instead of the soft json_object path. Gemini
enforces its response_schema natively; providers without a strict mode ignore it.
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
cached_prefix: Opaque handle from ``get_or_create_cached_prefix`` for the
cacheable system prefix, or None. Providers without explicit prompt
caching ignore it (and the wrapper only forwards it when set).
Returns:
If return_usage=False: Parsed response if response_format is provided, otherwise text content.
@@ -108,6 +114,7 @@ class LLMInterface(ABC):
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
cached_prefix: str | None = None,
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support.
@@ -137,6 +144,46 @@ class LLMInterface(ABC):
"""
return False
# ── Prompt prefix caching (optional, per-provider) ─────────────────────────
def supports_prompt_caching(self) -> bool:
"""Whether this provider can cache a reusable prompt prefix.
Default False. Providers that return True must implement
``get_or_create_cached_prefix`` and honour the ``cached_prefix`` argument
of ``call`` / ``call_with_tools``.
"""
return False
async def get_or_create_cached_prefix(
self,
*,
system_instruction: str,
response_schema: Any | None = None,
tools: list[dict[str, Any]] | None = None,
) -> str | None:
"""Cache a reusable prompt prefix and return an opaque handle, or None.
The engine has already decided WHAT is cacheable: it puts the stable,
bank-agnostic instructions in ``system_instruction`` (plus ``tools``) and
keeps all per-request / per-bank data (documents, facts, the bank mission)
in the user message. A provider only chooses HOW to cache that prefix:
- Explicit-cache providers (e.g. Gemini ``CachedContent``): create the
cache, return its handle; the engine passes the handle back via
``call(cached_prefix=...)`` and the provider then drops the prefix from
the request, billing it at the cached rate.
- Automatic-cache providers (e.g. OpenAI): no handle needed — caching is
transparent as long as the prefix is a stable leading block, which it
already is. They can keep this default (return None) and still benefit.
- Inline-marker providers (e.g. Anthropic ``cache_control``): mark the
prefix block inside ``call`` instead; may also keep this default.
Returns None when caching is disabled/unsupported or the prefix is too
small; callers MUST fall back to an uncached call in that case.
"""
return None
async def submit_batch(
self,
requests: list[dict[str, Any]],
@@ -0,0 +1,540 @@
"""Per-bank LLM request tracing.
Opt-in, fire-and-forget recording of every LLM call Hindsight makes (both
successes and failures) into the ``llm_requests`` table, per bank. Each row
captures the input messages, the model output, token usage (input / output /
cached / total), finish reason, and caller metadata. Disabled by default —
controlled by ``HINDSIGHT_API_LLM_TRACE_ENABLED``.
This plugs into the OpenTelemetry **GenAI** recording pattern: providers already
call ``tracing.get_span_recorder().record_llm_call(...)`` on success, so the DB
tracer is registered as one of those recorders (alongside the OTLP span
exporter) rather than hooking the call path with custom code. Failures, which
providers don't report to the recorder, are forwarded from the LLM wrapper.
Bank/operation attribution is carried via a ContextVar set by
``ConfiguredLLMProvider`` (see ``llm_wrapper.py``); outside a traced context
``bank_id`` is recorded as NULL.
"""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from collections.abc import Callable, Iterable
from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any
from pydantic import BaseModel
from .db_utils import acquire_with_retry
logger = logging.getLogger(__name__)
# ── bank/operation attribution (carried across the async call chain) ──────────
@dataclass
class LLMTraceContext:
"""Attribution for in-flight LLM calls, bound by ``ConfiguredLLMProvider``.
``trace_id`` and ``operation_span_id`` are generated once per operation
invocation (one ``with_config`` call), so every LLM call of a single
reflect/retain/consolidation run shares them — reproducing the OTel
parent (operation span) → children (LLM calls) hierarchy in the DB.
"""
bank_id: str | None = None
operation: str | None = None # "retain" | "reflect" | "consolidation" | ...
metadata: dict[str, Any] = field(default_factory=dict)
trace_id: str | None = None
operation_span_id: str | None = None
# Memory_units this operation produced/consumed, accumulated at the DB-write
# sites and flushed onto every row of the trace at operation end (see
# LLMTraceRecorder.attach_memory_ids). Lets a retain/consolidation trace map
# to the memories it created (outputs) and consumed (source inputs).
created_memory_ids: list[str] = field(default_factory=list)
source_memory_ids: list[str] = field(default_factory=list)
_trace_ctx: ContextVar[LLMTraceContext | None] = ContextVar("hindsight_llm_trace_ctx", default=None)
# Per-call requested parameters (max_completion_tokens, temperature, response
# schema, tool_choice). Set by ``LLMProvider.call`` around the provider
# delegation so the recorder can attach them even though success is reported by
# the provider. Only includes values the caller actually set — never nulls.
_request_ctx: ContextVar[dict[str, Any] | None] = ContextVar("hindsight_llm_request_ctx", default=None)
# Per-call caller metadata (e.g. document_id for retain extraction). Set by
# engine code around a specific LLM call; merged into the row's metadata on top
# of the operation-level LLMTraceContext.metadata.
_call_metadata_ctx: ContextVar[dict[str, Any] | None] = ContextVar("hindsight_llm_call_metadata_ctx", default=None)
def set_trace_context(ctx: LLMTraceContext | None) -> Token:
"""Bind trace attribution to the current context. Returns a reset token."""
return _trace_ctx.set(ctx)
def reset_trace_context(token: Token) -> None:
"""Unwind a binding made by :func:`set_trace_context`."""
_trace_ctx.reset(token)
def set_request_context(params: dict[str, Any] | None) -> Token:
"""Bind the current LLM call's requested parameters. Returns a reset token."""
return _request_ctx.set(params)
def reset_request_context(token: Token) -> None:
"""Unwind a binding made by :func:`set_request_context`."""
_request_ctx.reset(token)
def current_request_context() -> dict[str, Any] | None:
"""Return the active call's requested parameters, or None."""
return _request_ctx.get()
def set_call_metadata(metadata: dict[str, Any] | None) -> Token:
"""Bind per-call caller metadata (e.g. ``{"document_id": ...}``)."""
return _call_metadata_ctx.set(metadata)
def reset_call_metadata(token: Token) -> None:
"""Unwind a binding made by :func:`set_call_metadata`."""
_call_metadata_ctx.reset(token)
def current_call_metadata() -> dict[str, Any] | None:
"""Return the active call's caller metadata, or None."""
return _call_metadata_ctx.get()
def current_trace_context() -> LLMTraceContext | None:
"""Return the active trace attribution, or None outside a traced context."""
return _trace_ctx.get()
def trace_context_of(llm_config: Any) -> LLMTraceContext | None:
"""Return a configured provider's operation trace context, or None.
Real providers expose ``trace_context()`` (``ConfiguredLLMProvider``); test
or mock substitutes may not, so this degrades gracefully rather than raising
— tracing is best-effort and must never break an operation.
"""
getter = getattr(llm_config, "trace_context", None)
return getter() if callable(getter) else None
def record_created_memory_ids(ids: Iterable[str]) -> None:
"""Accumulate output memory_units onto the active operation trace.
No-op outside a traced operation context (e.g. tracing disabled). Child
asyncio tasks inherit the same ``LLMTraceContext`` object, so appends from
parallel consolidation batches land on one shared list.
"""
ctx = _trace_ctx.get()
if ctx is not None:
ctx.created_memory_ids.extend(str(i) for i in ids)
def record_source_memory_ids(ids: Iterable[str]) -> None:
"""Accumulate consumed/source memory_units onto the active operation trace.
No-op outside a traced operation context.
"""
ctx = _trace_ctx.get()
if ctx is not None:
ctx.source_memory_ids.extend(str(i) for i in ids)
# ── serialization helpers ─────────────────────────────────────────────────────
def _json_default(obj: Any) -> Any:
"""JSON serializer for objects not serializable by default."""
if isinstance(obj, datetime):
return obj.isoformat()
if isinstance(obj, uuid.UUID):
return str(obj)
if isinstance(obj, bytes):
return "<bytes>"
if isinstance(obj, set):
return list(obj)
model_dump = getattr(obj, "model_dump", None)
if callable(model_dump):
try:
return model_dump(mode="json")
except Exception:
return str(obj)
return str(obj)
def _safe_json(data: Any, max_chars: int) -> str | None:
"""Serialize ``data`` to a JSON string, truncating beyond ``max_chars``.
Returns None on total failure. Truncation preserves valid JSON by wrapping
the oversized payload in a marker object with a preview.
"""
if data is None:
return None
try:
serialized = json.dumps(data, default=_json_default)
except Exception:
logger.debug("Failed to serialize llm trace data", exc_info=True)
try:
serialized = json.dumps(str(data))
except Exception:
return None
if max_chars and max_chars > 0 and len(serialized) > max_chars:
return json.dumps({"_truncated": True, "_original_chars": len(serialized), "preview": serialized[:max_chars]})
return serialized
# ── record ────────────────────────────────────────────────────────────────────
@dataclass
class LLMRequestRecord:
"""A single LLM request trace row."""
provider: str
model: str | None
scope: str
status: str # "success" | "error"
started_at: datetime
ended_at: datetime
bank_id: str | None = None
operation: str | None = None
trace_id: str | None = None
span_id: str | None = None
parent_span_id: str | None = None
input: Any = None
output: Any = None
error: str | None = None
input_tokens: int | None = None
output_tokens: int | None = None
cached_tokens: int | None = None
total_tokens: int | None = None
llm_info: dict[str, Any] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
@property
def duration_ms(self) -> int:
return int((self.ended_at - self.started_at).total_seconds() * 1000)
# ── read models (returned by MemoryEngine query methods, served by the API) ───
class LLMRequestEntry(BaseModel):
"""A single LLM request trace row, as returned by the read API."""
id: str
bank_id: str | None
operation: str | None
scope: str | None
trace_id: str | None
span_id: str | None
parent_span_id: str | None
provider: str | None
model: str | None
status: str
started_at: str | None
ended_at: str | None
duration_ms: int | None
input_tokens: int | None
output_tokens: int | None
cached_tokens: int | None
total_tokens: int | None
# Arbitrary JSON (message list, string, or object) — open `Any` so the
# OpenAPI schema stays a plain open type the Go SDK generator can model.
input: Any = None
output: Any = None
error: str | None
llm_info: dict[str, Any]
metadata: dict[str, Any]
class LLMRequestListResponse(BaseModel):
"""Paginated list of LLM request traces for a bank."""
bank_id: str
total: int
limit: int
offset: int
items: list[LLMRequestEntry]
class LLMRequestTokenSums(BaseModel):
"""Token totals for a time bucket."""
input: int
output: int
cached: int
total: int
class LLMRequestStatsBucket(BaseModel):
"""A single time bucket in LLM request stats."""
time: str
statuses: dict[str, int]
total: int
tokens: LLMRequestTokenSums
class LLMRequestStatsResponse(BaseModel):
"""LLM request counts and token sums grouped by time bucket."""
bank_id: str
period: str
trunc: str
start: str
buckets: list[LLMRequestStatsBucket]
# ── recorder / writer ─────────────────────────────────────────────────────────
class LLMTraceRecorder:
"""GenAI span recorder that writes per-bank LLM traces to ``llm_requests``.
Implements ``record_llm_call`` so it can be registered with
:func:`hindsight_api.tracing.register_span_recorder`. Writes are
fire-and-forget and never surface errors into the calling path. Retention of
old rows is handled by the background :class:`MaintenanceLoop`.
"""
def __init__(
self,
pool_getter: Callable[[], Any],
schema_getter: Callable[[], str],
enabled: bool,
allowed_scopes: list[str],
max_chars: int = 50000,
) -> None:
self._pool_getter = pool_getter
self._schema_getter = schema_getter
self._enabled = enabled
self._allowed_scopes: frozenset[str] | None = frozenset(allowed_scopes) if allowed_scopes else None
self._max_chars = max_chars
# In-flight fire-and-forget write tasks, bucketed by trace_id so
# attach_memory_ids can await only *its own* operation's writes before the
# post-operation UPDATE (otherwise the UPDATE could race ahead of the
# INSERTs it patches — but it must not block on unrelated operations).
self._pending: dict[str | None, set[asyncio.Task]] = {}
def is_enabled(self, scope: str) -> bool:
"""Whether tracing is active for the given call scope."""
if not self._enabled:
return False
if self._allowed_scopes is not None:
return scope in self._allowed_scopes
return True
# ── GenAI recorder interface ──────────────────────────────────────────────
def record_llm_call(
self,
provider: str,
model: str,
scope: str,
messages: list[dict[str, Any]],
response_content: Any = None,
input_tokens: int = 0,
output_tokens: int = 0,
duration: float = 0.0,
finish_reason: str | None = None,
error: BaseException | None = None,
tool_calls: list[dict[str, Any]] | None = None,
cached_tokens: int = 0,
**_extra: Any,
) -> None:
"""Build a trace record from a GenAI call and schedule a DB write."""
if not self.is_enabled(scope):
return
ctx = current_trace_context()
ended_at = datetime.now(timezone.utc)
started_at = ended_at - timedelta(seconds=max(0.0, duration))
# Operation-level metadata + any per-call metadata (e.g. document_id).
metadata = dict(ctx.metadata) if ctx else {}
call_metadata = current_call_metadata()
if call_metadata:
metadata.update(call_metadata)
llm_info: dict[str, Any] = {}
request_params = current_request_context()
if request_params:
llm_info["request"] = dict(request_params)
if finish_reason:
llm_info["finish_reason"] = finish_reason
if tool_calls:
llm_info["tool_calls"] = [tc.get("name", "") for tc in tool_calls]
record = LLMRequestRecord(
provider=provider,
model=model,
scope=scope,
status="error" if error is not None else "success",
started_at=started_at,
ended_at=ended_at,
bank_id=ctx.bank_id if ctx else None,
operation=ctx.operation if ctx else None,
# OTel-style hierarchy: all calls of one operation invocation share
# the context's trace_id and point at its operation span; this call
# gets its own span_id.
trace_id=ctx.trace_id if ctx else None,
span_id=str(uuid.uuid4()),
parent_span_id=ctx.operation_span_id if ctx else None,
input=messages,
output=None if error is not None else response_content,
error=f"{type(error).__name__}: {error}" if error is not None else None,
input_tokens=input_tokens or None,
output_tokens=output_tokens or None,
cached_tokens=cached_tokens or None,
total_tokens=(input_tokens + output_tokens) or None,
llm_info=llm_info,
metadata=metadata,
)
self._record_fire_and_forget(record)
def _record_fire_and_forget(self, record: LLMRequestRecord) -> None:
"""Schedule a trace write as a background task."""
try:
task = asyncio.create_task(self._safe_write(record))
except RuntimeError:
# No running event loop (e.g. during shutdown)
logger.debug("Cannot schedule llm trace write: no running event loop")
return
key = record.trace_id
self._pending.setdefault(key, set()).add(task)
task.add_done_callback(lambda t, k=key: self._discard_pending(k, t))
def _discard_pending(self, key: str | None, task: asyncio.Task) -> None:
bucket = self._pending.get(key)
if bucket is not None:
bucket.discard(task)
if not bucket:
self._pending.pop(key, None)
async def _safe_write(self, record: LLMRequestRecord) -> None:
"""Write a trace row. Errors are logged, never raised."""
pool = self._pool_getter()
if pool is None:
logger.debug("LLM trace skipped: pool not available")
return
try:
schema = self._schema_getter()
table = f"{schema}.llm_requests"
async with acquire_with_retry(pool, max_retries=1) as conn:
await conn.execute(
f"""
INSERT INTO {table}
(id, bank_id, operation, scope, trace_id, span_id, parent_span_id,
provider, model, status,
started_at, ended_at, duration_ms,
input_tokens, output_tokens, cached_tokens, total_tokens,
input, output, error, llm_info, metadata)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17,
$18::jsonb, $19::jsonb, $20, $21::jsonb, $22::jsonb)
""",
uuid.uuid4(),
record.bank_id,
record.operation,
record.scope,
record.trace_id,
record.span_id,
record.parent_span_id,
record.provider,
record.model,
record.status,
record.started_at,
record.ended_at,
record.duration_ms,
record.input_tokens,
record.output_tokens,
record.cached_tokens,
record.total_tokens,
_safe_json(record.input, self._max_chars),
_safe_json(record.output, self._max_chars),
record.error,
_safe_json(record.llm_info, self._max_chars) or "{}",
_safe_json(record.metadata, self._max_chars) or "{}",
)
except Exception as e:
logger.warning(f"LLM trace write failed for scope={record.scope}: {e}")
async def _flush_pending(self, trace_id: str) -> None:
"""Await this trace's in-flight writes so its rows exist before an UPDATE."""
pending = [t for t in self._pending.get(trace_id, ()) if not t.done()]
if pending:
await asyncio.gather(*pending, return_exceptions=True)
def attach_memory_ids(
self,
trace_ctx: LLMTraceContext | None,
*,
created: list[str] | None = None,
source: list[str] | None = None,
) -> None:
"""Map a finished operation's memory_units onto every row of its trace.
Merges the explicitly passed ids with any accumulated on the context
(``record_created_memory_ids`` / ``record_source_memory_ids``), de-dupes
preserving order, and patches ``metadata.memory_ids`` (outputs created)
and ``metadata.source_memory_ids`` (inputs consumed) on all rows sharing
the trace_id. No-op when tracing is off or nothing was produced.
Fire-and-forget: the snapshotted patch is applied on a background task so
the retain/consolidation operation never waits on the trace write. The
ids are snapshotted synchronously here because the caller may reset the
context immediately after.
"""
if not self._enabled or trace_ctx is None or not trace_ctx.trace_id:
return
created_ids = list(dict.fromkeys([*(created or []), *trace_ctx.created_memory_ids]))
source_ids = list(dict.fromkeys([*(source or []), *trace_ctx.source_memory_ids]))
patch: dict[str, Any] = {}
if created_ids:
patch["memory_ids"] = created_ids
if source_ids:
patch["source_memory_ids"] = source_ids
if not patch:
return
try:
asyncio.create_task(self._attach_memory_ids(trace_ctx.bank_id, trace_ctx.trace_id, patch))
except RuntimeError:
logger.debug("Cannot schedule llm trace memory_id attach: no running event loop")
async def _attach_memory_ids(self, bank_id: str | None, trace_id: str, patch: dict[str, Any]) -> None:
"""Background worker: flush this trace's writes, then patch its rows."""
# The trace-row INSERTs are fire-and-forget; flush *this trace's* writes
# so the UPDATE patches rows that already exist rather than racing ahead
# of them (without blocking on unrelated operations' pending writes).
await self._flush_pending(trace_id)
pool = self._pool_getter()
if pool is None:
return
try:
schema = self._schema_getter()
table = f"{schema}.llm_requests"
async with acquire_with_retry(pool, max_retries=1) as conn:
await conn.execute(
f"UPDATE {table} SET metadata = metadata || $3::jsonb WHERE bank_id = $1 AND trace_id = $2",
bank_id,
trace_id,
json.dumps(patch),
)
except Exception as e:
logger.warning(f"LLM trace memory_id attach failed for trace={trace_id}: {e}")
@@ -114,6 +114,32 @@ def _semaphores_for_scope(scope: str) -> list[asyncio.Semaphore]:
return [per_op, _global_llm_semaphore]
def _request_params(
*,
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str | None = None,
response_format: Any | None = None,
tool_choice: str | dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""Build the requested-params bag for tracing — only values the caller set.
Omitting unset values avoids the misleading nulls we used to record (e.g.
consolidation, which passes no token cap), while surfacing the real cap for
callers that do set one (e.g. retain's ``retain_max_completion_tokens``).
"""
params: dict[str, Any] = {}
if max_completion_tokens is not None:
params["max_completion_tokens"] = max_completion_tokens
if temperature is not None:
params["temperature"] = temperature
if response_format is not None:
params["response_schema"] = getattr(response_format, "__name__", None) or "structured"
if tool_choice is not None and tool_choice != "auto":
params["tool_choice"] = tool_choice if isinstance(tool_choice, str) else "named"
return params or None
def sanitize_text(text: str | None) -> str | None:
"""
Sanitize text by removing characters that break downstream systems.
@@ -229,6 +255,7 @@ def create_llm_provider(
vertexai_region: str | None = None,
vertexai_credentials: Any = None,
gemini_safety_settings: list | None = None,
prompt_cache_enabled: bool = False,
litellmrouter_config: dict[str, Any] | None = None,
) -> Any: # Returns LLMInterface
"""
@@ -242,7 +269,11 @@ def create_llm_provider(
reasoning_effort: Reasoning effort level for supported providers.
groq_service_tier: Groq service tier (for Groq provider) - "on_demand", "flex", or "auto".
openai_service_tier: OpenAI service tier (for OpenAI provider) - None (default) or "flex" (50% cheaper).
extra_body: Extra body params merged into OpenAI-compatible API calls.
extra_body: Extra request-body params merged into the provider's native
call. Threaded into OpenAI-compatible, Fireworks, Anthropic, Gemini/
VertexAI and LiteLLM providers (each merges them in its own parameter
space). Keys must use each provider's native names (e.g. ``max_tokens``
for OpenAI/Anthropic vs ``max_output_tokens`` for Gemini).
default_headers: Custom headers passed as ``default_headers`` to provider SDK clients
(used by operators routing through proxies / request-tracing middleware). Currently
wired into the Anthropic provider; other providers may opt in as needed.
@@ -317,6 +348,8 @@ def create_llm_provider(
vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials,
gemini_safety_settings=gemini_safety_settings,
prompt_cache_enabled=prompt_cache_enabled,
extra_body=extra_body,
)
elif provider_lower == "anthropic":
@@ -327,6 +360,7 @@ def create_llm_provider(
model=model,
reasoning_effort=reasoning_effort,
default_headers=default_headers,
extra_body=extra_body,
)
elif provider_lower == "litellm":
@@ -336,6 +370,7 @@ def create_llm_provider(
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
)
elif provider_lower == "litellmrouter":
@@ -353,6 +388,7 @@ def create_llm_provider(
model=model,
config=litellmrouter_config,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
)
elif provider_lower == "bedrock":
@@ -364,6 +400,7 @@ def create_llm_provider(
base_url=base_url,
model=bedrock_model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
)
elif provider_lower == "llamacpp":
@@ -442,6 +479,7 @@ class LLMProvider:
groq_service_tier: str | None = None,
openai_service_tier: str | None = None,
gemini_safety_settings: list | None = None,
prompt_cache_enabled: bool = False,
extra_body: dict[str, Any] | None = None,
default_headers: dict[str, str] | None = None,
litellmrouter_config: dict[str, Any] | None = None,
@@ -458,7 +496,8 @@ class LLMProvider:
groq_service_tier: Groq service tier ("on_demand", "flex", "auto") - from config.
openai_service_tier: OpenAI service tier (None or "flex") - from config.
gemini_safety_settings: Safety settings for Gemini/VertexAI providers.
extra_body: Extra body params merged into OpenAI-compatible API calls.
extra_body: Extra request-body params merged into the provider's native call
(OpenAI-compatible, Fireworks, Anthropic, Gemini/VertexAI, LiteLLM).
default_headers: Custom headers passed as ``default_headers`` to provider SDK clients.
Used by operators routing through proxies / request-tracing middleware. Falls
back to ``HindsightConfig.llm_default_headers`` (env: ``HINDSIGHT_API_LLM_DEFAULT_HEADERS``)
@@ -480,6 +519,11 @@ class LLMProvider:
self.openai_service_tier = openai_service_tier
# Gemini safety settings (instance default; can be overridden per-request via context var)
self.gemini_safety_settings = gemini_safety_settings
# Gemini prompt caching: when True, retain extraction (and any future
# caller that opts in) will reuse a CachedContent prefix to cut
# input-token cost. Off by default so the change is observable behind
# a flip rather than a silent behaviour change on upgrade.
self.prompt_cache_enabled = prompt_cache_enabled
# Extra body params for OpenAI-compatible providers (e.g. chat_template_kwargs)
self.extra_body = extra_body
# Default headers passed to provider SDK clients (e.g. proxy auth, request tracing).
@@ -598,6 +642,21 @@ class LLMProvider:
except Exception:
pass # Config may not be initialized in test environments
# Prompt-prefix caching is a provider-agnostic toggle (default on): resolve
# it from the static server config for every provider when the caller didn't
# pass an explicit override. Providers that don't support caching ignore the
# value; only those that implement get_or_create_cached_prefix act on it.
if not self.prompt_cache_enabled:
from ..config import DEFAULT_LLM_PROMPT_CACHE_ENABLED, _get_raw_config
try:
raw_config = _get_raw_config()
self.prompt_cache_enabled = bool(
getattr(raw_config, "llm_prompt_cache_enabled", DEFAULT_LLM_PROMPT_CACHE_ENABLED)
)
except Exception:
pass # Config may not be initialized in test environments
# For litellmrouter: prefer an explicit chain from the caller (per-op
# construction in MemoryEngine threads the right chain through). If the caller
# didn't supply one, fall back to the global ``llm_litellmrouter_config`` so
@@ -626,6 +685,7 @@ class LLMProvider:
vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials,
gemini_safety_settings=self.gemini_safety_settings,
prompt_cache_enabled=self.prompt_cache_enabled,
litellmrouter_config=router_config,
)
@@ -689,6 +749,7 @@ class LLMProvider:
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
cached_prefix: str | None = None,
) -> Any:
"""
Make an LLM API call with retry logic.
@@ -703,7 +764,10 @@ class LLMProvider:
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
skip_validation: Return raw JSON without Pydantic validation.
strict_schema: Use strict JSON schema enforcement (OpenAI only). Guarantees all required fields.
strict_schema: Per-call override requesting grammar-enforced (json_schema strict)
structured output instead of the soft json_object path. The server-level
HINDSIGHT_API_LLM_STRICT_SCHEMA flag is OR-ed in here so it applies to every call;
providers without a strict mode ignore it.
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
Returns:
@@ -723,33 +787,83 @@ class LLMProvider:
structured = "+structured" if response_format is not None else ""
set_stage(f"llm.{self.provider}.{scope}{structured}")
async with AsyncExitStack() as stack:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
# Resolve strict-schema once, here, rather than in each provider: the
# per-call argument OR the server-level HINDSIGHT_API_LLM_STRICT_SCHEMA
# flag. Providers with a json_schema response_format (OpenAI-compatible,
# LiteLLM) then grammar-enforce structured output instead of the fragile
# soft json_object path; Gemini already enforces its native response_schema,
# and providers without a strict mode simply ignore the flag.
from ..config import get_config
# Delegate to provider implementation
result = await self._provider_impl.call(
messages=messages,
response_format=response_format,
strict_schema = strict_schema or get_config().llm_strict_schema
# LLM call observability flows through the OTel GenAI recorder
# (tracing.get_span_recorder().record_llm_call). Provider implementations
# record successful calls; we forward failures here since they don't.
# The requested params are stashed in a contextvar (only what the caller
# actually set) so the recorder can attach them to either path.
from ..tracing import get_span_recorder
from .llm_trace import reset_request_context, set_request_context
call_start = time.monotonic()
request_token = set_request_context(
_request_params(
max_completion_tokens=max_completion_tokens,
temperature=temperature,
scope=scope,
max_retries=max_retries,
initial_backoff=initial_backoff,
max_backoff=max_backoff,
skip_validation=skip_validation,
strict_schema=strict_schema,
return_usage=return_usage,
response_format=response_format,
)
)
try:
async with AsyncExitStack() as stack:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
# Backward compatibility: Update mock call tracking for mock provider
# This allows existing tests using LLMProvider._mock_calls to continue working
if self.provider == "mock":
from .providers.mock_llm import MockLLM
# cached_prefix is only set for providers that returned a handle
# from get_or_create_cached_prefix() (e.g. Gemini); it's None for
# the rest. Forward it only when present so providers that don't
# implement caching keep their call() signature untouched.
cache_kwarg = {"cached_prefix": cached_prefix} if cached_prefix is not None else {}
try:
# Delegate to provider implementation
result = await self._provider_impl.call(
messages=messages,
response_format=response_format,
max_completion_tokens=max_completion_tokens,
temperature=temperature,
scope=scope,
max_retries=max_retries,
initial_backoff=initial_backoff,
max_backoff=max_backoff,
skip_validation=skip_validation,
strict_schema=strict_schema,
return_usage=return_usage,
**cache_kwarg,
)
except Exception as e:
get_span_recorder().record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=None,
input_tokens=0,
output_tokens=0,
duration=time.monotonic() - call_start,
error=e,
)
raise
if isinstance(self._provider_impl, MockLLM):
# Sync the mock calls from provider implementation to wrapper
self._mock_calls = self._provider_impl.get_mock_calls()
# Backward compatibility: Update mock call tracking for mock provider
# This allows existing tests using LLMProvider._mock_calls to continue working
if self.provider == "mock":
from .providers.mock_llm import MockLLM
if isinstance(self._provider_impl, MockLLM):
# Sync the mock calls from provider implementation to wrapper
self._mock_calls = self._provider_impl.get_mock_calls()
finally:
reset_request_context(request_token)
return result
@@ -764,6 +878,7 @@ class LLMProvider:
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
cached_prefix: str | None = None,
) -> "LLMToolCallResult":
"""
Make an LLM API call with tool/function calling support.
@@ -786,31 +901,66 @@ class LLMProvider:
set_stage(f"llm.{self.provider}.{scope}+tools")
async with AsyncExitStack() as stack:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
# Failures forwarded to the GenAI recorder; successes recorded by providers.
from ..tracing import get_span_recorder
from .llm_trace import reset_request_context, set_request_context
# Delegate to provider implementation
result = await self._provider_impl.call_with_tools(
messages=messages,
tools=tools,
call_start = time.monotonic()
request_token = set_request_context(
_request_params(
max_completion_tokens=max_completion_tokens,
temperature=temperature,
scope=scope,
max_retries=max_retries,
initial_backoff=initial_backoff,
max_backoff=max_backoff,
tool_choice=tool_choice,
)
)
try:
async with AsyncExitStack() as stack:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
# Backward compatibility: Update mock call tracking for mock provider
# This allows existing tests using LLMProvider._mock_calls to continue working
if self.provider == "mock":
from .providers.mock_llm import MockLLM
# cached_prefix is only set for providers that returned a handle
# from get_or_create_cached_prefix(); forward it only when present
# so non-caching providers keep their signature (same as call()).
cache_kwarg = {"cached_prefix": cached_prefix} if cached_prefix is not None else {}
try:
# Delegate to provider implementation
result = await self._provider_impl.call_with_tools(
messages=messages,
tools=tools,
max_completion_tokens=max_completion_tokens,
temperature=temperature,
scope=scope,
max_retries=max_retries,
initial_backoff=initial_backoff,
max_backoff=max_backoff,
tool_choice=tool_choice,
**cache_kwarg,
)
except Exception as e:
get_span_recorder().record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=None,
input_tokens=0,
output_tokens=0,
duration=time.monotonic() - call_start,
error=e,
)
raise
if isinstance(self._provider_impl, MockLLM):
# Sync the mock calls from provider implementation to wrapper
self._mock_calls = self._provider_impl.get_mock_calls()
# Backward compatibility: Update mock call tracking for mock provider
# This allows existing tests using LLMProvider._mock_calls to continue working
if self.provider == "mock":
from .providers.mock_llm import MockLLM
if isinstance(self._provider_impl, MockLLM):
# Sync the mock calls from provider implementation to wrapper
self._mock_calls = self._provider_impl.get_mock_calls()
finally:
reset_request_context(request_token)
return result
@@ -914,7 +1064,14 @@ class LLMProvider:
# SDK will automatically check for authentication when first used
# No need to verify here - let it fail gracefully on first call with helpful error
def with_config(self, config: Any) -> "ConfiguredLLMProvider":
def with_config(
self,
config: Any,
*,
bank_id: str | None = None,
operation: str | None = None,
metadata: dict[str, Any] | None = None,
) -> "ConfiguredLLMProvider":
"""
Return a configured wrapper for a specific bank operation.
@@ -924,12 +1081,31 @@ class LLMProvider:
Args:
config: Resolved ``HindsightConfig`` for the current bank/request.
bank_id: Bank the operation runs for; attributed to LLM trace rows.
operation: Logical operation label ("retain", "reflect", ...) for
LLM trace rows.
metadata: Optional extra caller metadata stored on trace rows.
Returns:
A ``ConfiguredLLMProvider`` that delegates to this provider with
the supplied config applied.
"""
return ConfiguredLLMProvider(self, config.llm_gemini_safety_settings)
trace_ctx = None
if bank_id is not None or operation is not None or metadata:
from .llm_trace import LLMTraceContext
# One trace + operation span per with_config() call — i.e. per
# operation invocation. Every LLM call made through this wrapper
# shares them, so a reflect/retain/consolidation run groups its
# calls as parent (operation) → children (LLM calls).
trace_ctx = LLMTraceContext(
bank_id=bank_id,
operation=operation,
metadata=dict(metadata or {}),
trace_id=str(uuid.uuid4()),
operation_span_id=str(uuid.uuid4()),
)
return ConfiguredLLMProvider(self, config.llm_gemini_safety_settings, trace_ctx)
async def cleanup(self) -> None:
"""Clean up resources (e.g. stop llamacpp subprocess)."""
@@ -993,10 +1169,16 @@ class ConfiguredLLMProvider:
any changes.
"""
def __init__(self, provider: "LLMProvider", gemini_safety_settings: list | None) -> None:
def __init__(
self,
provider: "LLMProvider",
gemini_safety_settings: list | None,
trace_ctx: Any | None = None,
) -> None:
# Use object.__setattr__ to avoid triggering __getattr__
object.__setattr__(self, "_provider", provider)
object.__setattr__(self, "_gemini_safety_settings", gemini_safety_settings)
object.__setattr__(self, "_trace_ctx", trace_ctx)
# ── attribute passthrough ──────────────────────────────────────────────────
@@ -1009,10 +1191,12 @@ class ConfiguredLLMProvider:
from .providers.gemini_llm import _safety_settings_ctx
token = _safety_settings_ctx.set(object.__getattribute__(self, "_gemini_safety_settings"))
trace_token = self._bind_trace_context()
try:
return await object.__getattribute__(self, "_provider").call(messages=messages, **kwargs)
finally:
_safety_settings_ctx.reset(token)
self._reset_trace_context(trace_token)
async def call_with_tools(
self,
@@ -1023,12 +1207,38 @@ class ConfiguredLLMProvider:
from .providers.gemini_llm import _safety_settings_ctx
token = _safety_settings_ctx.set(object.__getattribute__(self, "_gemini_safety_settings"))
trace_token = self._bind_trace_context()
try:
return await object.__getattribute__(self, "_provider").call_with_tools(
messages=messages, tools=tools, **kwargs
)
finally:
_safety_settings_ctx.reset(token)
self._reset_trace_context(trace_token)
def trace_context(self) -> Any | None:
"""The operation-level LLM trace context (or None when untraced).
Lets the engine attach the operation's produced/consumed memory_ids to
this run's trace rows once they're known (after the LLM calls).
"""
return object.__getattribute__(self, "_trace_ctx")
def _bind_trace_context(self) -> Any | None:
"""Bind bank/operation attribution for the duration of one call."""
trace_ctx = object.__getattribute__(self, "_trace_ctx")
if trace_ctx is None:
return None
from .llm_trace import set_trace_context
return set_trace_context(trace_ctx)
def _reset_trace_context(self, trace_token: Any | None) -> None:
if trace_token is None:
return
from .llm_trace import reset_trace_context
reset_trace_context(trace_token)
# Backwards compatibility alias
@@ -0,0 +1,214 @@
"""Background maintenance loop.
A single periodic loop that drives all of Hindsight's recurring housekeeping
from one place, so we don't spawn a separate ``asyncio`` task per concern:
- **Retention sweeps** (hourly): delete ``audit_log`` and ``llm_requests`` rows
older than their configured retention, across *all* tenant schemas.
- **Consolidation reconcile** (configurable, default 5 min): re-schedule
consolidation for banks that have eligible-but-unscheduled facts and no
in-flight consolidation. This recovers facts that were stranded when a
consolidation operation failed terminally and left them with
``consolidated_at IS NULL AND consolidation_failed_at IS NULL`` and nothing to
re-trigger them.
The loop wakes on a short fixed tick and runs each job when its own
``last_run + interval`` is due (run-at-start, then on interval), so adding jobs
with different cadences doesn't burst CPU. Cross-tenant discovery goes through
server-side PL/pgSQL routines (``public.schemas_with_expired_rows`` and
``public.banks_needing_consolidation``) — one round-trip each — instead of a
per-schema query storm, which matters at thousands of tenants.
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import TYPE_CHECKING
from ..config import HindsightConfig, get_config
from ..models import RequestContext
from .db_utils import acquire_with_retry
from .schema import _is_oracle
if TYPE_CHECKING:
from .memory_engine import MemoryEngine
logger = logging.getLogger(__name__)
# Short tick so jobs with different cadences share one loop without per-job tasks.
_TICK_SECONDS = 60
# Retention sweeps are not time-sensitive; hourly matches the previous per-sweep cadence.
_RETENTION_INTERVAL_SECONDS = 3600
class MaintenanceLoop:
"""Owns the single periodic maintenance task for a :class:`MemoryEngine`."""
def __init__(self, engine: "MemoryEngine") -> None:
self._engine = engine
self._task: asyncio.Task | None = None
self._stop = asyncio.Event()
# Monotonic timestamps of the last run per job, keyed by job name.
self._last_run: dict[str, float] = {}
# ── lifecycle ──────────────────────────────────────────────────────────
def start(self) -> None:
"""Start the loop if any maintenance job is enabled. Idempotent."""
if self._task and not self._task.done():
return
# PostgreSQL-only: the retention sweeps target PG-only tables (audit_log,
# llm_requests) and the reconcile relies on PG-only PL/pgSQL routines
# installed by the maintenance-routines migration. Oracle support is
# intentionally absent (mirrors that PG-only migration).
if _is_oracle():
logger.debug("Maintenance loop not started: PostgreSQL-only")
return
if not self._any_job_enabled():
logger.debug("Maintenance loop not started: no jobs enabled")
return
self._stop.clear()
try:
self._task = asyncio.create_task(self._run())
except RuntimeError:
logger.debug("Cannot start maintenance loop: no running event loop")
async def stop(self) -> None:
"""Stop the loop and wait for the current tick to finish."""
self._stop.set()
if self._task and not self._task.done():
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
@staticmethod
def _any_job_enabled() -> bool:
cfg = get_config()
reconcile_on = cfg.consolidation_reconcile_interval_seconds > 0
audit_on = cfg.audit_log_enabled and cfg.audit_log_retention_days > 0
llm_on = cfg.llm_trace_enabled and cfg.llm_trace_retention_days > 0
return reconcile_on or audit_on or llm_on
# ── loop ───────────────────────────────────────────────────────────────
async def _run(self) -> None:
while not self._stop.is_set():
try:
await self._tick()
except Exception:
logger.exception("Maintenance tick failed")
try:
await asyncio.wait_for(self._stop.wait(), timeout=_TICK_SECONDS)
except asyncio.TimeoutError:
pass
def _is_due(self, job: str, interval_seconds: int) -> bool:
"""True if ``job`` has never run or its interval has elapsed; marks it run now."""
now = time.monotonic()
last = self._last_run.get(job)
if last is not None and (now - last) < interval_seconds:
return False
self._last_run[job] = now
return True
async def _tick(self) -> None:
cfg = get_config()
if self._is_due("retention", _RETENTION_INTERVAL_SECONDS):
await self._run_retention(cfg)
interval = cfg.consolidation_reconcile_interval_seconds
if interval > 0 and self._is_due("reconcile", interval):
await self._run_reconcile()
# ── retention ──────────────────────────────────────────────────────────
async def _run_retention(self, cfg: HindsightConfig) -> None:
# Retention days are static server-level config, so one global cutoff
# applies to every tenant schema (the routine sweeps them all).
if cfg.audit_log_enabled and cfg.audit_log_retention_days > 0:
await self._purge_expired("audit_log", "started_at", cfg.audit_log_retention_days)
if cfg.llm_trace_enabled and cfg.llm_trace_retention_days > 0:
await self._purge_expired("llm_requests", "started_at", cfg.llm_trace_retention_days)
async def _purge_expired(self, table: str, ts_col: str, days: int) -> None:
"""Delete rows older than ``days`` from ``table`` across every tenant schema."""
backend = self._engine._backend
try:
async with acquire_with_retry(backend, max_retries=1) as conn:
rows = await conn.fetch(
"SELECT * FROM public.schemas_with_expired_rows($1, $2, $3)", table, ts_col, days
)
for row in rows:
schema = row[0]
# schema names come from pg_class; quote defensively all the same.
qschema = '"' + schema.replace('"', '""') + '"'
result = await conn.execute(
f"DELETE FROM {qschema}.{table} WHERE {ts_col} < NOW() - make_interval(days => $1)",
days,
)
if result and result != "DELETE 0":
logger.info(f"Retention sweep {schema}.{table}: {result}")
except Exception as e:
logger.warning(f"Retention sweep failed for {table}: {e}")
# ── consolidation reconcile ──────────────────────────────────────────────
async def _run_reconcile(self) -> None:
"""Re-schedule consolidation for banks with eligible-but-unscheduled facts."""
engine = self._engine
try:
async with acquire_with_retry(engine._backend, max_retries=1) as conn:
rows = await conn.fetch("SELECT schema_name, bank_id FROM public.banks_needing_consolidation()")
except Exception as e:
logger.warning(f"Consolidation reconcile discovery failed: {e}")
return
if not rows:
return
# Only enqueue into schemas the worker actually polls (tenant discovery),
# otherwise the op would never be claimed and would block future reconciles
# for that bank. The tenant_id (when the extension provides one) lets
# config resolution honor tenant-level overrides.
try:
tenants = await engine._tenant_extension.list_tenants()
except Exception as e:
logger.warning(f"Consolidation reconcile tenant discovery failed: {e}")
return
tenant_by_schema = {t.schema: t for t in tenants}
default_schema = get_config().database_schema
from .memory_engine import _current_schema
submitted = 0
skipped_unknown = 0
for row in rows:
schema = row["schema_name"]
bank_id = row["bank_id"]
tenant = tenant_by_schema.get(schema)
if tenant is None and schema != default_schema:
skipped_unknown += 1
continue
tenant_id = tenant.tenant_id if tenant else None
token = _current_schema.set(schema)
try:
context = RequestContext(internal=True, tenant_id=tenant_id)
resolved = await engine._config_resolver.resolve_full_config(bank_id, context)
# Mirror the retain-time auto-consolidation gate (memory_engine): both
# observations and auto-consolidation must be enabled for this bank.
if not (resolved.enable_observations and resolved.enable_auto_consolidation):
continue
await engine.submit_async_consolidation(bank_id=bank_id, request_context=context)
submitted += 1
except Exception as e:
logger.warning(f"Consolidation reconcile failed for bank {bank_id} in {schema}: {e}")
finally:
_current_schema.reset(token)
if submitted or skipped_unknown:
logger.info(
f"Consolidation reconcile: scheduled {submitted} bank(s)"
+ (f", skipped {skipped_unknown} in unrecognized schema(s)" if skipped_unknown else "")
)
File diff suppressed because it is too large Load Diff
@@ -5,8 +5,10 @@ These dataclasses define the structure of result_metadata for different operatio
The metadata is exposed in the API for debugging purposes and may change without notice.
"""
from dataclasses import asdict, dataclass
from typing import Any
from dataclasses import asdict, dataclass, field
from typing import Any, Mapping
MAX_EXTRACTION_ERROR_SAMPLES = 5
@dataclass
@@ -48,6 +50,79 @@ class RetainMetadata:
return asdict(self)
@dataclass
class RetainExtractionErrors:
"""Non-fatal fact extraction failures observed inside one retain operation."""
count: int = 0
sample: list[str] = field(default_factory=list)
def add(self, message: str) -> None:
"""Record one extraction error while keeping the stored sample bounded."""
self.count += 1
if len(self.sample) < MAX_EXTRACTION_ERROR_SAMPLES:
self.sample.append(message[:500])
def merge_metadata(self, metadata: Mapping[str, Any]) -> None:
"""Merge errors already present on an operation result_metadata object."""
self.count += int(metadata.get("extraction_errors_count") or 0)
sample = metadata.get("extraction_errors_sample") or []
if isinstance(sample, str):
sample = [sample]
if isinstance(sample, list):
for entry in sample:
if isinstance(entry, str) and len(self.sample) < MAX_EXTRACTION_ERROR_SAMPLES:
self.sample.append(entry[:500])
def to_dict(self) -> dict[str, Any]:
"""Convert to the public result_metadata field shape."""
data: dict[str, Any] = {"extraction_errors_count": self.count}
if self.sample:
data["extraction_errors_sample"] = self.sample
return data
@dataclass
class RetainOutcomeMetadata:
"""Machine-readable outcome metadata for a completed retain operation."""
unit_ids_count: int
extraction_errors_count: int = 0
extraction_errors_sample: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
"""Convert to dict for JSON serialization, omitting empty optional samples."""
data: dict[str, Any] = {
"unit_ids_count": self.unit_ids_count,
"extraction_errors_count": self.extraction_errors_count,
}
if self.extraction_errors_sample:
data["extraction_errors_sample"] = self.extraction_errors_sample[:MAX_EXTRACTION_ERROR_SAMPLES]
return data
@dataclass
class RetainOutcomeAggregate:
"""Aggregate retain outcome metadata from child retain operations."""
unit_ids_count: int = 0
extraction_errors: RetainExtractionErrors = field(default_factory=RetainExtractionErrors)
def add_metadata(self, metadata: Mapping[str, Any]) -> None:
"""Fold one child operation's result_metadata into the aggregate."""
self.unit_ids_count += int(metadata.get("unit_ids_count") or 0)
self.extraction_errors.merge_metadata(metadata)
def to_outcome_metadata(self) -> RetainOutcomeMetadata:
"""Return the aggregate in the public result_metadata field shape."""
return RetainOutcomeMetadata(
unit_ids_count=self.unit_ids_count,
extraction_errors_count=self.extraction_errors.count,
extraction_errors_sample=self.extraction_errors.sample,
)
@dataclass
class ConsolidationMetadata:
"""Metadata for consolidation operations."""
@@ -38,6 +38,7 @@ class AnthropicLLM(LLMInterface):
reasoning_effort: str = "low",
timeout: float = 300.0,
default_headers: dict[str, str] | None = None,
extra_body: dict[str, Any] | None = None,
**kwargs: Any,
):
"""
@@ -54,6 +55,10 @@ class AnthropicLLM(LLMInterface):
the Anthropic SDK client. Used by operators routing through proxies
or request-tracing middleware. Sourced from ``llm_default_headers`` in
``HindsightConfig`` (env: ``HINDSIGHT_API_LLM_DEFAULT_HEADERS``).
extra_body: Extra request-body params (e.g. ``{"temperature": 0.2,
"top_p": 0.9, "top_k": 40}``) passed via the Anthropic SDK's
``extra_body`` so they merge into the JSON sent to the Messages API.
Sourced from ``llm_extra_body`` (env: ``HINDSIGHT_API_LLM_EXTRA_BODY``).
**kwargs: Additional provider-specific parameters.
"""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
@@ -61,6 +66,9 @@ class AnthropicLLM(LLMInterface):
if not self.api_key:
raise ValueError("API key is required for Anthropic provider")
# User-configured extra body params (merged into every Messages API call)
self._extra_body = extra_body or {}
# Import and initialize Anthropic client
try:
from anthropic import AsyncAnthropic
@@ -178,6 +186,9 @@ class AnthropicLLM(LLMInterface):
if system_prompt:
call_params["system"] = system_prompt
if self._extra_body:
call_params["extra_body"] = self._extra_body
last_exception = None
for attempt in range(max_retries + 1):
@@ -216,6 +227,7 @@ class AnthropicLLM(LLMInterface):
input_tokens = response.usage.input_tokens or 0 if response.usage else 0
output_tokens = response.usage.output_tokens or 0 if response.usage else 0
total_tokens = input_tokens + output_tokens
cached_tokens = getattr(response.usage, "cache_read_input_tokens", 0) or 0 if response.usage else 0
# Record LLM metrics
metrics = get_metrics_collector()
@@ -245,6 +257,7 @@ class AnthropicLLM(LLMInterface):
duration=duration,
finish_reason=finish_reason,
error=None,
cached_tokens=cached_tokens,
)
# Log slow calls
@@ -260,6 +273,7 @@ class AnthropicLLM(LLMInterface):
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
cached_tokens=cached_tokens,
)
return result, token_usage
return result
@@ -394,6 +408,9 @@ class AnthropicLLM(LLMInterface):
if system_prompt:
call_params["system"] = system_prompt
if self._extra_body:
call_params["extra_body"] = self._extra_body
last_exception = None
for attempt in range(max_retries + 1):
try:
@@ -0,0 +1,316 @@
"""Gemini context-cache manager.
Wraps the ``google-genai`` SDK's CachedContent API to let callers reuse a
stable system_instruction + response_schema prefix across many requests.
Cached input tokens are billed at ~10× lower than fresh input tokens
(check the current Gemini pricing for the exact ratio per model), so for
workloads that repeatedly send a large fixed prefix with a small variable
user message — fact extraction, structured tagging, classification — the
input-cost savings are substantial.
This module owns only the create/refresh/lookup lifecycle. It is up to
the caller to (a) decide that the prefix is stable enough to cache, and
(b) pass the returned cache name to ``GeminiLLM.call()``. When the
returned name is ``None`` (because Gemini rejected the create — most
commonly because the prefix is smaller than the model's minimum), the
caller MUST fall back to a non-cached call.
Cardinality
-----------
The intended cache count per process is small (≲100 entries). Each
entry corresponds to one combination of (model, system_instruction,
response_schema). If a caller sees the cache grow unboundedly it
indicates the system_instruction contains per-request data that should
move into the user message instead.
TTL
---
Gemini's CachedContent has a TTL bounded by the model (currently 1h
for most generally-available models). This manager refreshes proactively
at ``ttl_safety_margin`` before expiry. If a cached entry has expired
between refreshes the next call will recreate it transparently.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import time
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
# Default TTL: 55 minutes. Gemini's hard max for CachedContent is 1 hour
# for most models; we refresh 5 minutes early so a request landing right
# at the boundary doesn't race against expiry.
_DEFAULT_TTL_SECONDS = 55 * 60
_DEFAULT_REFRESH_MARGIN_SECONDS = 5 * 60
# Cap on the cache-create network call. It runs while holding the manager lock, so
# a hung create would block every concurrent caller (e.g. all chunks of a 10-chunk
# retain batch waiting on the cold-start create). On timeout the create soft-fails
# to None and callers proceed uncached, rather than stalling the whole batch.
_DEFAULT_CREATE_TIMEOUT_SECONDS = 30.0
@dataclass
class _CacheEntry:
name: str # The CachedContent resource name returned by Gemini.
created_at: float
ttl_seconds: int
class GeminiCacheManager:
"""Per-process map of (prefix fingerprint) → CachedContent name.
Thread-safe across asyncio tasks via a single ``asyncio.Lock``. The
create/refresh calls are serialised; this is fine because cache
creation is a one-shot warm-up per fingerprint (subsequent reads are
pure dict lookups outside the lock).
Not shared across pods — each worker / api replica builds its own
cache. The cost of cold-starting one extra full-price call per pod
per fingerprint per hour is negligible compared to the steady-state
savings.
"""
def __init__(
self,
client: Any,
*,
ttl_seconds: int = _DEFAULT_TTL_SECONDS,
refresh_margin_seconds: int = _DEFAULT_REFRESH_MARGIN_SECONDS,
create_timeout_seconds: float = _DEFAULT_CREATE_TIMEOUT_SECONDS,
) -> None:
self._client = client
self._ttl_seconds = ttl_seconds
self._refresh_margin_seconds = refresh_margin_seconds
self._create_timeout_seconds = create_timeout_seconds
self._entries: dict[str, _CacheEntry] = {}
self._lock = asyncio.Lock()
@staticmethod
def fingerprint(
model: str,
system_instruction: str,
response_schema: Any | None = None,
tools: list[dict[str, Any]] | None = None,
) -> str:
"""Stable hash of the cacheable surface.
``response_schema`` may be a Pydantic class, a dict, or ``None``.
Pydantic schemas are normalised by serialising via
``model_json_schema()`` and stripping the auto-generated
``"title"`` fields so two dynamically-built models with the same
shape but different class names hash identically. This matters
for callers (e.g. fact extraction) that rebuild the schema
class on every request via a builder helper — without the
normalisation the cache would never hit.
``tools`` is the OpenAI-style tools list (each entry has a
``"function"`` dict with name/description/parameters). When
supplied, the tool definitions become part of the cache key so a
loop that adds or renames a tool gets a fresh cache and doesn't
silently use a stale schema. Tools are serialised with
``sort_keys=True`` to neutralise dict-ordering drift.
"""
hasher = hashlib.sha256()
hasher.update(model.encode("utf-8"))
hasher.update(b"\x00")
hasher.update(system_instruction.encode("utf-8"))
hasher.update(b"\x00")
if response_schema is None:
hasher.update(b"none")
elif hasattr(response_schema, "model_json_schema"):
try:
schema = response_schema.model_json_schema()
_strip_titles(schema)
hasher.update(json.dumps(schema, sort_keys=True).encode("utf-8"))
except Exception:
# Fall back to class identity if the schema can't be serialised.
hasher.update(repr(response_schema).encode("utf-8"))
else:
try:
hasher.update(json.dumps(response_schema, sort_keys=True).encode("utf-8"))
except (TypeError, ValueError):
hasher.update(repr(response_schema).encode("utf-8"))
hasher.update(b"\x00")
if tools:
try:
hasher.update(json.dumps(tools, sort_keys=True).encode("utf-8"))
except (TypeError, ValueError):
hasher.update(repr(tools).encode("utf-8"))
else:
hasher.update(b"no-tools")
return hasher.hexdigest()
async def get_or_create(
self,
*,
model: str,
system_instruction: str,
response_schema: Any | None = None,
tools: list[dict[str, Any]] | None = None,
) -> str | None:
"""Return a CachedContent resource name for the given prefix, or
``None`` if Gemini rejects the create (prefix too small, model
does not support caching, etc.).
``tools`` is the OpenAI-style tools list. When supplied, the tool
definitions are baked into the CachedContent so the caller's
``call_with_tools`` doesn't need to resend them on every
iteration. Pass ``None`` for non-tool calls.
``None`` return is a normal, expected value — the caller falls
back to an uncached call and the system continues to work.
"""
key = self.fingerprint(model, system_instruction, response_schema, tools)
async with self._lock:
entry = self._entries.get(key)
if entry is not None and self._is_fresh(entry):
return entry.name
# Need to (re)create. Pop the stale entry first so a failed
# create doesn't leave a name we'd return on the next call.
self._entries.pop(key, None)
try:
cache_name = await self._create_cache(
model=model,
system_instruction=system_instruction,
tools=tools,
)
except _CacheNotEligible as e:
logger.debug(
"GeminiCacheManager: prefix not eligible for caching (model=%s, reason=%s) — caller will fall back",
model,
e,
)
return None
except Exception:
logger.exception(
"GeminiCacheManager: failed to create cached content "
"(model=%s); caller will fall back to uncached call",
model,
)
return None
if cache_name is None:
return None
self._entries[key] = _CacheEntry(
name=cache_name,
created_at=time.monotonic(),
ttl_seconds=self._ttl_seconds,
)
return cache_name
def _is_fresh(self, entry: _CacheEntry) -> bool:
"""An entry is fresh if it's young enough that the next request
won't race against the TTL expiry."""
age = time.monotonic() - entry.created_at
return age < (entry.ttl_seconds - self._refresh_margin_seconds)
def invalidate(self, name: str) -> None:
"""Forget a cache name that the server rejected (expired/deleted/invalid).
Called by the provider when a generate request using this CachedContent
fails, so the next ``get_or_create`` recreates it instead of handing back
the dead name again. Best-effort and sync — drops the matching entry from
the in-process map; the orphaned server-side cache (if any) ages out on
its own TTL.
"""
for key, entry in list(self._entries.items()):
if entry.name == name:
self._entries.pop(key, None)
async def _create_cache(
self,
*,
model: str,
system_instruction: str,
tools: list[dict[str, Any]] | None = None,
) -> str | None:
"""Wrap ``client.aio.caches.create`` with the config we want.
The SDK surface differs slightly across google-genai versions;
this implementation targets the >=1.0.0 line where caches live
under ``client.aio.caches``.
"""
# Lazy import so this module doesn't require the SDK at import time.
from google.genai import types as genai_types
# A CachedContent only holds reusable *input* — system_instruction,
# contents, tools, ttl. ``response_schema``/``response_mime_type`` are
# generation-time output constraints and the SDK rejects them here
# (``CreateCachedContentConfig`` forbids those fields). They are applied
# per-request on the GenerateContentConfig instead — see the call sites,
# which set them alongside ``cached_content``. ``response_schema`` is
# still part of the fingerprint so a schema change keys a fresh cache.
config_kwargs: dict[str, Any] = {
"system_instruction": system_instruction,
"ttl": f"{self._ttl_seconds}s",
}
if tools:
# OpenAI-style {"function": {...}} entries must be converted to
# Gemini's Tool/FunctionDeclaration shape before caching.
gemini_tools = []
for tool in tools:
func = tool.get("function", {})
gemini_tools.append(
genai_types.Tool(
function_declarations=[
genai_types.FunctionDeclaration(
name=func.get("name", ""),
description=func.get("description", ""),
parameters=func.get("parameters"),
)
]
)
)
config_kwargs["tools"] = gemini_tools
try:
cached = await asyncio.wait_for(
self._client.aio.caches.create(
model=model,
config=genai_types.CreateCachedContentConfig(**config_kwargs),
),
timeout=self._create_timeout_seconds,
)
except Exception as e:
# Gemini returns a 400 with a "minimum token count" message
# when the prefix is too small. We treat this as a soft
# "not eligible" signal rather than a real error so callers
# silently fall back to non-cached.
msg = str(e).lower()
if "minimum" in msg or "too small" in msg or "too short" in msg:
raise _CacheNotEligible(str(e)) from e
raise
return getattr(cached, "name", None)
class _CacheNotEligible(Exception):
"""Raised when Gemini rejects the cache create because the prefix
is below the model's minimum cacheable size. Treated as a soft
fallback by the caller, not an error."""
def _strip_titles(node: Any) -> None:
"""Recursively remove auto-generated ``"title"`` keys from a JSON
Schema-like dict tree, in place. Pydantic seeds these from the
Python class name, which means structurally-identical schemas built
from differently-named classes look distinct to a naive hash."""
if isinstance(node, dict):
node.pop("title", None)
for v in node.values():
_strip_titles(v)
elif isinstance(node, list):
for item in node:
_strip_titles(item)
@@ -70,6 +70,22 @@ class GeminiLLM(LLMInterface):
# Safety settings: None means use Gemini's defaults
self._safety_settings: list | None = kwargs.get("gemini_safety_settings")
# User-configured extra params merged into the GenerateContentConfig of
# every call. Gemini's request body nests generation params, so we expose
# them in the SDK's native config space rather than as a raw body merge:
# keys must be GenerateContentConfig fields (e.g. temperature, top_p,
# top_k, max_output_tokens, seed). Sourced from llm_extra_body
# (env: HINDSIGHT_API_LLM_EXTRA_BODY).
self._extra_body: dict[str, Any] = kwargs.get("extra_body") or {}
# Context-cache manager. Lazy-initialized on first cache lookup so
# nothing happens for models/workloads that never reach it. The instance
# default here is off (a directly-constructed GeminiLLM doesn't cache); the
# server-level default is on and flows in via the prompt_cache_enabled kwarg
# resolved from config in LLMProvider.
self._cache_manager: Any | None = None
self._prompt_cache_enabled: bool = bool(kwargs.get("prompt_cache_enabled", False))
if self._is_vertexai:
self._init_vertexai(**kwargs)
else:
@@ -168,6 +184,7 @@ class GeminiLLM(LLMInterface):
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
cached_prefix: str | None = None,
) -> Any:
"""
Make a Gemini/VertexAI API call with retry logic.
@@ -182,8 +199,17 @@ class GeminiLLM(LLMInterface):
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
skip_validation: Return raw JSON without Pydantic validation.
strict_schema: Use strict JSON schema enforcement (not supported by Gemini).
strict_schema: Ignored — Gemini always grammar-enforces structured output via its
native response_schema, so it is strict regardless of this flag.
return_usage: If True, return tuple (result, TokenUsage).
cached_prefix: Optional CachedContent resource name (from
``GeminiCacheManager.get_or_create``). When set, the
system_instruction is assumed to live in the cache; this call
skips resending it and the cached prefix is billed at the
cached-input rate instead of the standard input rate. The
response_schema is still sent per-request (it is not cacheable).
Pass ``None`` to use the
normal uncached path.
Returns:
If return_usage=False: Parsed response if response_format provided, else text.
@@ -191,9 +217,14 @@ class GeminiLLM(LLMInterface):
"""
start_time = time.time()
# Convert OpenAI-style messages to Gemini format
# Convert OpenAI-style messages to Gemini format. We ALWAYS build
# system_instruction (even when a cache is in use): the config builder
# below omits it from the request while the cache carries the prefix, but
# it must be available so the cached-call-failed safety net can re-send it
# inline. Whether it's actually sent is decided in _build_generation_config.
system_instruction = None
gemini_contents = []
using_cache = cached_prefix is not None
for msg in messages:
role = msg.get("role", "user")
@@ -209,7 +240,9 @@ class GeminiLLM(LLMInterface):
else:
gemini_contents.append(genai_types.Content(role="user", parts=[genai_types.Part(text=content)]))
# Add JSON schema instruction if response_format is provided
# Add the JSON schema as a textual hint in the system_instruction (matching
# the normal uncached path). Structured output is still enforced via
# response_schema regardless; this is just guidance text.
if response_format is not None and hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
@@ -218,32 +251,44 @@ class GeminiLLM(LLMInterface):
else:
system_instruction = schema_msg
# Build generation config
config_kwargs: dict[str, Any] = {}
if system_instruction:
config_kwargs["system_instruction"] = system_instruction
if response_format is not None:
config_kwargs["response_mime_type"] = "application/json"
config_kwargs["response_schema"] = response_format
if temperature is not None:
config_kwargs["temperature"] = temperature
# Gemini's equivalent of OpenAI-style max_completion_tokens is max_output_tokens.
# Without it the model can produce arbitrarily long responses, ignoring the
# caller's intended cap (e.g. mental_models max_tokens during refresh).
if max_completion_tokens is not None:
config_kwargs["max_output_tokens"] = max_completion_tokens
# Apply safety settings: context var (per-request bank override) takes precedence over instance default
effective_safety_settings = _safety_settings_ctx.get()
if effective_safety_settings is None:
effective_safety_settings = self._safety_settings
if effective_safety_settings is not None:
config_kwargs["safety_settings"] = [
genai_types.SafetySetting(category=s["category"], threshold=s["threshold"])
for s in effective_safety_settings
]
generation_config = genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None
# Build generation config. ``cached_content`` and ``system_instruction``
# are mutually exclusive (the cache IS the prefix; the SDK rejects
# re-sending it). ``response_schema``/``response_mime_type`` are
# request-level output constraints — NOT cacheable — so they're set on
# every structured call, including cached ones where they ride alongside
# ``cached_content``. Built as a closure so we can rebuild it WITHOUT the
# cache and retry inline if a stale/invalid CachedContent makes the call fail.
def _build_generation_config(use_cache: bool) -> "genai_types.GenerateContentConfig | None":
# Seed with user-configured extra params; explicit settings below win.
config_kwargs: dict[str, Any] = dict(self._extra_body)
if use_cache:
config_kwargs["cached_content"] = cached_prefix
elif system_instruction:
config_kwargs["system_instruction"] = system_instruction
if response_format is not None:
config_kwargs["response_mime_type"] = "application/json"
config_kwargs["response_schema"] = response_format
if temperature is not None:
config_kwargs["temperature"] = temperature
# Gemini's equivalent of OpenAI-style max_completion_tokens is max_output_tokens.
# Without it the model can produce arbitrarily long responses, ignoring the
# caller's intended cap (e.g. mental_models max_tokens during refresh).
if max_completion_tokens is not None:
config_kwargs["max_output_tokens"] = max_completion_tokens
if effective_safety_settings is not None:
config_kwargs["safety_settings"] = [
genai_types.SafetySetting(category=s["category"], threshold=s["threshold"])
for s in effective_safety_settings
]
return genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None
cache_active = using_cache
generation_config = _build_generation_config(cache_active)
last_exception = None
@@ -288,13 +333,24 @@ class GeminiLLM(LLMInterface):
else:
result = content
# Extract token usage
# Extract token usage. ``cached_content_token_count`` and
# ``thoughts_token_count`` are populated on the Gemini 2.5+
# family; treat missing fields as 0 so older models still
# record sensible metrics.
input_tokens = 0
output_tokens = 0
cached_input_tokens = 0
thoughts_tokens = 0
cached_tokens = 0
if hasattr(response, "usage_metadata") and response.usage_metadata:
usage = response.usage_metadata
input_tokens = usage.prompt_token_count or 0
output_tokens = usage.candidates_token_count or 0
cached_input_tokens = getattr(usage, "cached_content_token_count", 0) or 0
thoughts_tokens = getattr(usage, "thoughts_token_count", 0) or 0
# Tracing/TokenUsage consume ``cached_tokens``; metrics consume
# ``cached_input_tokens`` — same value, two downstream names.
cached_tokens = cached_input_tokens
# Record metrics
duration = time.time() - start_time
@@ -307,6 +363,8 @@ class GeminiLLM(LLMInterface):
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
cached_input_tokens=cached_input_tokens,
thoughts_tokens=thoughts_tokens,
)
# Record trace span
@@ -330,6 +388,7 @@ class GeminiLLM(LLMInterface):
duration=duration,
finish_reason=finish_reason,
error=None,
cached_tokens=cached_tokens,
)
# Log slow calls
@@ -345,6 +404,7 @@ class GeminiLLM(LLMInterface):
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=input_tokens + output_tokens,
cached_tokens=cached_tokens,
)
return result, token_usage
return result
@@ -366,6 +426,20 @@ class GeminiLLM(LLMInterface):
logger.error(f"Gemini auth error (HTTP {e.code}), not retrying: {str(e)}")
raise
# Cached-request safety net: a stale/invalid/expired CachedContent
# (or an incompatibility like cache + tool_config) surfaces as a 400.
# Retrying the same cached request can't recover, so on the first
# such failure drop the cache, invalidate it so later operations
# recreate it, and retry THIS call inline with the prefix inlined.
# Caching must never break a request.
if cache_active and e.code == 400:
logger.warning(f"Gemini cached call failed (400); retrying uncached. Reason: {str(e)}")
if self._cache_manager is not None and cached_prefix is not None:
self._cache_manager.invalidate(cached_prefix)
cache_active = False
generation_config = _build_generation_config(cache_active)
continue
# Retry on retryable errors (rate limits, server errors, client errors)
if e.code in (400, 429, 500, 502, 503, 504) or (e.code and e.code >= 500):
last_exception = e
@@ -399,6 +473,7 @@ class GeminiLLM(LLMInterface):
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
cached_prefix: str | None = None,
) -> LLMToolCallResult:
"""
Make a Gemini/VertexAI API call with tool/function calling support.
@@ -413,27 +488,39 @@ class GeminiLLM(LLMInterface):
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools (Gemini uses "auto" only).
cached_prefix: Optional CachedContent resource name (from
``GeminiCacheManager.get_or_create`` with ``tools=...``). When
set, the system_instruction and tool definitions are assumed
to live in the cache; this call will skip resending them and
the cached prefix is billed at the cached-input rate. The
``tools`` argument is still required (the caller may pass
an empty list when the cache holds them) so existing call
sites don't break.
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
start_time = time.time()
using_cache = cached_prefix is not None
# Convert tools to Gemini format
# Convert tools to Gemini format. When the cache is in use, the
# tool definitions are baked into the CachedContent at create time
# and the SDK rejects re-sending them alongside ``cached_content``.
gemini_tools = []
for tool in tools:
func = tool.get("function", {})
gemini_tools.append(
genai_types.Tool(
function_declarations=[
genai_types.FunctionDeclaration(
name=func.get("name", ""),
description=func.get("description", ""),
parameters=func.get("parameters"),
)
]
if not using_cache:
for tool in tools:
func = tool.get("function", {})
gemini_tools.append(
genai_types.Tool(
function_declarations=[
genai_types.FunctionDeclaration(
name=func.get("name", ""),
description=func.get("description", ""),
parameters=func.get("parameters"),
)
]
)
)
)
# Convert messages
system_instruction = None
@@ -446,6 +533,10 @@ class GeminiLLM(LLMInterface):
content = msg.get("content", "")
if role == "system":
# Always capture system_instruction. _build_tools_config omits it
# (and tools) from the request while the cache carries the prefix,
# but it must be available so the cached-call-failed safety net can
# re-send the prefix + tools inline.
system_instruction = (system_instruction + "\n\n" + content) if system_instruction else content
i += 1
elif role == "tool":
@@ -493,49 +584,63 @@ class GeminiLLM(LLMInterface):
gemini_contents.append(genai_types.Content(role="user", parts=[genai_types.Part(text=content)]))
i += 1
config_kwargs: dict[str, Any] = {"tools": gemini_tools}
if system_instruction:
config_kwargs["system_instruction"] = system_instruction
if temperature is not None:
config_kwargs["temperature"] = temperature
# See note in `call`: Gemini's max_output_tokens is the equivalent of
# OpenAI-style max_completion_tokens.
if max_completion_tokens is not None:
config_kwargs["max_output_tokens"] = max_completion_tokens
# Map OpenAI-style tool_choice to Gemini FunctionCallingConfig
if tool_choice == "required":
config_kwargs["tool_config"] = genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(
mode="ANY",
)
)
elif isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
fn_name = tool_choice.get("function", {}).get("name")
if fn_name:
config_kwargs["tool_config"] = genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(
mode="ANY",
allowed_function_names=[fn_name],
)
)
elif tool_choice == "none":
config_kwargs["tool_config"] = genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(mode="NONE")
)
# "auto" is the default (no tool_config needed)
# Apply safety settings: context var (per-request bank override) takes precedence over instance default
effective_safety_settings = _safety_settings_ctx.get()
if effective_safety_settings is None:
effective_safety_settings = self._safety_settings
if effective_safety_settings is not None:
config_kwargs["safety_settings"] = [
genai_types.SafetySetting(category=s["category"], threshold=s["threshold"])
for s in effective_safety_settings
]
config = genai_types.GenerateContentConfig(**config_kwargs)
# When using a cached prefix, the SDK rejects re-sending system_instruction
# or tools alongside ``cached_content`` — the cache IS the prefix.
# tool_config (mode / allowed_function_names) is a per-request decision and
# stays out of the cache. Built as a closure so we can rebuild it WITHOUT
# the cache and retry inline if a stale/invalid cache makes the call fail.
def _build_tools_config(use_cache: bool) -> "genai_types.GenerateContentConfig":
# Seed with user-configured extra params; explicit settings below win.
config_kwargs: dict[str, Any] = dict(self._extra_body)
if use_cache:
config_kwargs["cached_content"] = cached_prefix
else:
config_kwargs["tools"] = gemini_tools
if system_instruction:
config_kwargs["system_instruction"] = system_instruction
if temperature is not None:
config_kwargs["temperature"] = temperature
# See note in `call`: Gemini's max_output_tokens is the equivalent of
# OpenAI-style max_completion_tokens.
if max_completion_tokens is not None:
config_kwargs["max_output_tokens"] = max_completion_tokens
# Map OpenAI-style tool_choice to Gemini FunctionCallingConfig
if tool_choice == "required":
config_kwargs["tool_config"] = genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(
mode="ANY",
)
)
elif isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
fn_name = tool_choice.get("function", {}).get("name")
if fn_name:
config_kwargs["tool_config"] = genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(
mode="ANY",
allowed_function_names=[fn_name],
)
)
elif tool_choice == "none":
config_kwargs["tool_config"] = genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(mode="NONE")
)
# "auto" is the default (no tool_config needed)
if effective_safety_settings is not None:
config_kwargs["safety_settings"] = [
genai_types.SafetySetting(category=s["category"], threshold=s["threshold"])
for s in effective_safety_settings
]
return genai_types.GenerateContentConfig(**config_kwargs)
cache_active = using_cache
config = _build_tools_config(cache_active)
last_exception = None
for attempt in range(max_retries + 1):
@@ -578,12 +683,18 @@ class GeminiLLM(LLMInterface):
finish_reason = "tool_calls" if tool_calls else "stop"
# Extract token usage
# Extract token usage. ``cached_content_token_count`` and
# ``thoughts_token_count`` are populated on the Gemini 2.5+
# family; absent fields are treated as 0.
input_tokens = 0
output_tokens = 0
cached_input_tokens = 0
thoughts_tokens = 0
if response.usage_metadata:
input_tokens = response.usage_metadata.prompt_token_count or 0
output_tokens = response.usage_metadata.candidates_token_count or 0
cached_input_tokens = getattr(response.usage_metadata, "cached_content_token_count", 0) or 0
thoughts_tokens = getattr(response.usage_metadata, "thoughts_token_count", 0) or 0
# Record metrics
duration = time.time() - start_time
@@ -596,6 +707,8 @@ class GeminiLLM(LLMInterface):
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
cached_input_tokens=cached_input_tokens,
thoughts_tokens=thoughts_tokens,
)
# Record OpenTelemetry span
@@ -620,6 +733,7 @@ class GeminiLLM(LLMInterface):
finish_reason=finish_reason,
error=None,
tool_calls=tool_calls_dict,
cached_tokens=cached_input_tokens,
)
return LLMToolCallResult(
@@ -636,6 +750,18 @@ class GeminiLLM(LLMInterface):
logger.error(f"Gemini auth error (HTTP {e.code}), not retrying: {str(e)}")
raise
# Cached-request safety net (see ``call``): a stale/invalid cache or
# a cache+tool_config conflict surfaces as a 400. Drop the cache,
# invalidate it for later operations, and retry THIS call inline
# with the prefix + tools re-sent. Caching must never break a call.
if cache_active and e.code == 400:
logger.warning(f"Gemini cached tool call failed (400); retrying uncached. Reason: {str(e)}")
if self._cache_manager is not None and cached_prefix is not None:
self._cache_manager.invalidate(cached_prefix)
cache_active = False
config = _build_tools_config(cache_active)
continue
# Retry on retryable errors
last_exception = e
if attempt < max_retries:
@@ -652,6 +778,54 @@ class GeminiLLM(LLMInterface):
raise last_exception
raise RuntimeError("Gemini tool call failed")
def supports_prompt_caching(self) -> bool:
"""True when explicit Gemini context caching is enabled for this instance.
Reflects the opt-in flag so callers skip the cache lookup entirely when
it's off; ``get_or_create_cached_prefix`` also returns None in that case.
"""
return self._prompt_cache_enabled
async def get_or_create_cached_prefix(
self,
*,
system_instruction: str,
response_schema: Any | None = None,
tools: list[dict[str, Any]] | None = None,
) -> str | None:
"""Return a CachedContent resource name for the given prefix, or
``None`` if context caching is disabled, the provider doesn't
support it, or Gemini rejects the create (prefix too small, etc.).
``tools`` is the OpenAI-style tools list; pass it when caching a
prefix that will be used by ``call_with_tools()``. The fingerprint
includes the tool definitions so a loop that swaps a tool gets a
fresh cache automatically.
Callers pass the returned name to ``call(cached_prefix=...)``
or ``call_with_tools(cached_prefix=...)`` and treat ``None``
as "cache unavailable — use the normal path". That fallback is
essential: the system must continue to work if caching is disabled,
if Gemini's caching API has an outage, or if the prefix is below
the model's minimum cacheable size.
"""
if not self._prompt_cache_enabled:
return None
if self._client is None:
return None
if self._cache_manager is None:
# Lazy import so the cache module is only loaded when caching
# is actually used.
from hindsight_api.engine.providers.gemini_cache import GeminiCacheManager
self._cache_manager = GeminiCacheManager(self._client)
return await self._cache_manager.get_or_create(
model=self.model,
system_instruction=system_instruction,
response_schema=response_schema,
tools=tools,
)
async def cleanup(self) -> None:
"""Clean up resources (close connections, etc.)."""
# Gemini client doesn't require explicit cleanup
@@ -48,11 +48,18 @@ class LiteLLMLLM(LLMInterface):
model: str,
reasoning_effort: str = "low",
timeout: float = 300.0,
extra_body: dict[str, Any] | None = None,
**kwargs: Any,
):
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
self.timeout = timeout
self._litellm: Any = None
# User-configured extra params merged as top-level kwargs into every
# completion call so LiteLLM normalizes them per-provider (e.g. maps
# temperature/top_p/max_tokens across OpenAI, Anthropic, Bedrock, …) and
# drops any the target model rejects (litellm.drop_params=True below).
# Sourced from llm_extra_body (env: HINDSIGHT_API_LLM_EXTRA_BODY).
self._extra_body: dict[str, Any] = extra_body or {}
try:
import litellm
@@ -107,6 +114,11 @@ class LiteLLMLLM(LLMInterface):
if temperature is not None:
kwargs["temperature"] = temperature
# User-configured extras fill in only where the caller didn't set a value,
# so explicit per-call params (model, messages, temperature, …) always win.
for key, value in self._extra_body.items():
kwargs.setdefault(key, value)
return kwargs
# ── per-model output-tokens cap (shared with Router subclass) ────────────
@@ -162,6 +162,12 @@ class MockLLM(LLMInterface):
# Consolidation: produce a single observation from the input facts
# so the full pipeline (retain → consolidation → observation → recall) works.
result = self._build_mock_consolidation(messages, response_format)
elif scope == "consolidation_dedup" and response_format is not None:
# Observation dedup adjudication. Default to "keep" so mock-LLM consolidation never
# spuriously merges observations — this preserves the pre-dedup behaviour that
# deterministic consolidation tests assert (the generic branch below can't construct
# the model because its "action" field is required and has no default).
result = response_format(action="keep", reason="mock")
elif scope == "memory_think":
# Reflect: return a plausible text answer
result = "Based on the available information, the answer is related to the context provided."
@@ -7,7 +7,7 @@ This provider handles all OpenAI API-compatible models including:
- Groq: Fast inference with seed control and service tiers
- Ollama: Local models with native streaming API support
- LMStudio: Local models with OpenAI-compatible API
- MiniMax: MiniMax-M2.7 models with 1M context window
- MiniMax: MiniMax-M3 / MiniMax-M2.7 models with 1M context window
- DeepSeek: deepseek-v4-flash / deepseek-v4-pro / deepseek-chat / deepseek-reasoner via api.deepseek.com
- Opencode Go: deepseek-v4-flash via https://opencode.ai/zen/go/v1
@@ -44,6 +44,16 @@ logger = logging.getLogger(__name__)
DEFAULT_LLM_SEED = 4242
JSON_MODE_USER_HINT = "Return valid json only."
# Self-hosted OpenAI-compatible servers that advertise tool_choice="required"
# but silently ignore it: instead of forcing a tool call they return
# finish_reason "stop"/"tool_calls" with an EMPTY tool_calls array and no error.
# Reflect's agent loop then sees no tool call, runs synthesis with no retrieval,
# and answers "I don't have information" even when the bank holds the answer.
# See issues #1563 (LM Studio), #1179 (LM Studio + Qwen), #1877 (vLLM with
# --enable-auto-tool-choice). llama-server (the "llamacpp" provider) honors
# "required" correctly and is intentionally excluded (#1179).
_TOOL_CHOICE_REQUIRED_UNSUPPORTED_PROVIDERS = frozenset({"lmstudio", "ollama"})
class ProviderResponseError(RuntimeError):
"""Raised when a provider returns a success response without usable content."""
@@ -232,7 +242,7 @@ class OpenAICompatibleLLM(LLMInterface):
- Groq: Fast inference with seed control and service tiers
- Ollama: Local models with native streaming API for better structured output
- LMStudio: Local models with OpenAI-compatible API
- MiniMax: MiniMax-M2.7 models via OpenAI-compatible API (https://api.minimax.io/v1)
- MiniMax: MiniMax-M3 / MiniMax-M2.7 models via OpenAI-compatible API (https://api.minimax.io/v1)
- DeepSeek: deepseek-v4-flash / deepseek-v4-pro / deepseek-chat / deepseek-reasoner via https://api.deepseek.com
- opencode-go: deepseek-v4-flash via https://opencode.ai/zen/go/v1
"""
@@ -360,6 +370,21 @@ class OpenAICompatibleLLM(LLMInterface):
f"base_url={self.base_url or 'default'}"
)
def _drops_tool_choice_required(self) -> bool:
"""Whether this endpoint silently ignores ``tool_choice="required"``.
True for self-hosted OpenAI-compatible servers known to return an empty
tool_calls array for "required" instead of forcing a call (#1563/#1179/
#1877). Covers LM Studio / Ollama directly, plus any server reached via
the generic "openai" provider with a custom ``base_url`` (e.g. a local
vLLM endpoint). The real OpenAI API (no base_url override) honors
"required", and cloud providers keep their own default base_urls, so both
are left untouched.
"""
if self.provider in _TOOL_CHOICE_REQUIRED_UNSUPPORTED_PROVIDERS:
return True
return self.provider == "openai" and bool(self.base_url)
async def verify_connection(self) -> None:
"""
Verify that the provider is configured correctly by making a simple test call.
@@ -460,7 +485,9 @@ class OpenAICompatibleLLM(LLMInterface):
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
skip_validation: Return raw JSON without Pydantic validation.
strict_schema: Use strict JSON schema enforcement (OpenAI only).
strict_schema: Use strict json_schema (grammar-enforced) response_format instead of
the soft json_object path. Supported by OpenAI and schema-capable self-hosted
backends (llama.cpp, vLLM). Server-wide via HINDSIGHT_API_LLM_STRICT_SCHEMA.
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
Returns:
@@ -650,6 +677,9 @@ class OpenAICompatibleLLM(LLMInterface):
input_tokens = usage.prompt_tokens or 0 if usage else 0
output_tokens = usage.completion_tokens or 0 if usage else 0
total_tokens = usage.total_tokens or 0 if usage else 0
cached_tokens = 0
if usage and getattr(usage, "prompt_tokens_details", None):
cached_tokens = getattr(usage.prompt_tokens_details, "cached_tokens", 0) or 0
# Record LLM metrics
metrics = get_metrics_collector()
@@ -679,14 +709,12 @@ class OpenAICompatibleLLM(LLMInterface):
duration=duration,
finish_reason=finish_reason,
error=None,
cached_tokens=cached_tokens,
)
# Log slow calls
if duration > 10.0 and usage:
ratio = max(1, output_tokens) / max(1, input_tokens)
cached_tokens = 0
if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details:
cached_tokens = getattr(usage.prompt_tokens_details, "cached_tokens", 0) or 0
cache_info = f", cached_tokens={cached_tokens}" if cached_tokens > 0 else ""
logger.info(
f"slow llm call: scope={scope}, model={self.provider}/{self.model}, "
@@ -699,6 +727,7 @@ class OpenAICompatibleLLM(LLMInterface):
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
cached_tokens=cached_tokens,
)
return result, token_usage
return result
@@ -867,6 +896,16 @@ class OpenAICompatibleLLM(LLMInterface):
if request_tool_choice == "auto":
request_tool_choice = None
# vLLM (--enable-auto-tool-choice), LM Studio, Ollama and similar
# self-hosted servers silently drop tool_choice="required", returning an
# empty tool_calls array instead of forcing a call (#1563/#1179/#1877).
# Downgrade to auto (None) so the model still gets to call a tool. Named
# tool_choice dicts were already normalized to "required" + a single
# filtered tool above, so the call stays practically forced even under
# auto. The real OpenAI API honors "required" and is left untouched.
if request_tool_choice == "required" and self._drops_tool_choice_required():
request_tool_choice = None
# DeepSeek tool-call replies can carry provider-specific reasoning_content.
# The normalized tool result does not retain it, but replaying assistant
# tool_calls without the field can trigger a 400. DeepSeek accepts an
@@ -302,6 +302,24 @@ def _is_context_overflow_error(exc: Exception) -> bool:
)
def _all_mental_models_are_usable_and_fresh(tool_output: dict[str, Any]) -> bool:
"""Return whether every retrieved mental model is explicitly fresh and has answerable content.
Used to decide without an extra LLM call whether a forced
``search_mental_models`` result is trustworthy enough to hand control back
to the agent. A model is usable only when it is explicitly ``is_stale ==
False`` (an unknown/missing staleness flag is treated as unsafe) and has
non-empty content.
"""
models = tool_output.get("mental_models") or []
for model in models:
if model.get("is_stale") is not False:
return False
if not str(model.get("content") or "").strip():
return False
return True
async def run_reflect_agent(
llm_config: "LLMProvider",
bank_id: str,
@@ -382,6 +400,28 @@ async def run_reflect_agent(
{"role": "user", "content": query},
]
# Opt into context caching for the agentic tool loop. The system
# prompt and tool definitions are stable for the duration of this
# reflect call (and across reflects against the same bank), so
# caching them once and reusing across every iteration of the loop
# collapses the dominant input cost — the prefix repeated on every
# turn. ``get_or_create_cached_prefix`` returns None when caching is
# disabled, unsupported, or the prefix is too small; the
# ``call_with_tools`` invocation below transparently falls back to
# the uncached path in that case.
cached_prefix_name: str | None = None
provider_impl = getattr(llm_config, "_provider_impl", None)
if provider_impl is not None and provider_impl.supports_prompt_caching():
try:
cached_prefix_name = await provider_impl.get_or_create_cached_prefix(
system_instruction=system_prompt,
tools=tools,
)
except Exception:
# Caching is a soft optimisation; never let a cache-side
# error block a reflect.
cached_prefix_name = None
# Tracking
total_tools_called = 0
tool_trace: list[ToolCall] = []
@@ -442,6 +482,11 @@ async def run_reflect_agent(
)
consecutive_errors = 0
# When a forced ``search_mental_models`` returns fresh, usable models on a
# low/mid-budget call, we stop forcing the lower retrieval layers from this
# iteration onward and let the agent answer (or retrieve deeper itself)
# under ``auto`` tool choice. None means the full forced path still applies.
stop_forcing_from_iteration: int | None = None
for iteration in range(max_iterations):
is_last = iteration == max_iterations - 1
@@ -570,18 +615,31 @@ async def run_reflect_agent(
if include_recall:
forced_sequence.append("recall")
if iteration < len(forced_sequence):
iter_tool_choice: str | dict = {"type": "function", "function": {"name": forced_sequence[iteration]}}
if stop_forcing_from_iteration is not None and iteration >= stop_forcing_from_iteration:
# A fresh mental model already short-circuited the forced path.
iter_tool_choice: str | dict = "auto"
elif iteration < len(forced_sequence):
iter_tool_choice = {"type": "function", "function": {"name": forced_sequence[iteration]}}
else:
iter_tool_choice = "auto"
try:
result = await llm_config.call_with_tools(
ct_kwargs: dict[str, Any] = dict(
messages=messages,
tools=tools,
scope="reflect_tool_call",
tool_choice=iter_tool_choice,
)
# Gemini rejects ``cached_content`` alongside a per-request
# ``tool_config`` (forced tool choice): "CachedContent can not be used
# with GenerateContent request setting system_instruction, tools or
# tool_config." The forced-sequence iterations set tool_config, so only
# the ``auto`` iterations can reference the cache; forced iterations send
# the prefix inline. The cache (tools + system prompt) is identical
# either way, so this just limits *which* iterations are billed cached.
if cached_prefix_name is not None and iter_tool_choice == "auto":
ct_kwargs["cached_prefix"] = cached_prefix_name
result = await llm_config.call_with_tools(**ct_kwargs)
llm_duration = int((time.time() - llm_start) * 1000)
consecutive_errors = 0
total_input_tokens += result.input_tokens
@@ -924,6 +982,25 @@ async def run_reflect_agent(
for mm in output["mental_models"]:
if "id" in mm:
available_mental_model_ids.add(mm["id"])
# Deterministic short-circuit (no extra LLM call): on a
# low/mid-budget call, if every retrieved mental model is
# fresh and has usable content, stop forcing the lower
# retrieval layers. The next iteration runs under ``auto``
# tool choice, so the agent can answer directly when the
# mental model suffices, or — having just read it — issue a
# targeted ``search_observations``/``recall`` itself. Stale,
# empty, or missing mental models keep the full forced path.
if (
stop_forcing_from_iteration is None
and (budget or "low").lower() != "high"
and output.get("mental_models")
and _all_mental_models_are_usable_and_fresh(output)
):
stop_forcing_from_iteration = iteration + 1
logger.info(
f"[REFLECT {reflect_id}] Fresh mental models sufficient on iteration {iteration + 1}; "
"releasing forced lower-level retrieval to auto."
)
if (
normalized_tool_name == "search_observations"
@@ -93,6 +93,7 @@ class TokenUsage(BaseModel):
input_tokens: int = Field(default=0, description="Number of input/prompt tokens consumed")
output_tokens: int = Field(default=0, description="Number of output/completion tokens generated")
total_tokens: int = Field(default=0, description="Total tokens (input + output)")
cached_tokens: int = Field(default=0, description="Cached/cache-read prompt tokens, when reported by the provider")
def __add__(self, other: "TokenUsage") -> "TokenUsage":
"""Allow aggregating token usage from multiple calls."""
@@ -100,6 +101,7 @@ class TokenUsage(BaseModel):
input_tokens=self.input_tokens + other.input_tokens,
output_tokens=self.output_tokens + other.output_tokens,
total_tokens=self.total_tokens + other.total_tokens,
cached_tokens=self.cached_tokens + other.cached_tokens,
)
@@ -6,6 +6,7 @@ import json
import logging
import re
import uuid
from dataclasses import dataclass
from typing import TypedDict
from pydantic import BaseModel, Field
@@ -105,6 +106,18 @@ class BankProfile(TypedDict):
mission: str
@dataclass
class BankProfileResult:
"""Result of a get-or-create bank lookup.
``created`` is True when the bank row was freshly inserted on this call,
which callers use to drive the one-time HINDSIGHT_API_DEFAULT_BANK_TEMPLATE hook.
"""
profile: BankProfile
created: bool
class MissionMergeResponse(BaseModel):
"""LLM response for mission merge."""
@@ -123,8 +136,8 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
Returns:
BankProfile with name, typed DispositionTraits, and mission
"""
profile, _ = await get_or_create_bank_profile(pool, bank_id)
return profile
result = await get_or_create_bank_profile(pool, bank_id)
return result.profile
async def get_bank_profile_if_exists(pool, bank_id: str) -> BankProfile | None:
@@ -162,70 +175,89 @@ async def get_bank_profile_if_exists(pool, bank_id: str) -> BankProfile | None:
)
async def get_or_create_bank_profile(pool, bank_id: str) -> tuple[BankProfile, bool]:
async def get_or_create_bank_profile(pool, bank_id: str) -> BankProfileResult:
"""
Get bank profile, auto-creating with defaults if it doesn't exist.
Same as get_bank_profile, but also returns a flag indicating whether the
bank was freshly created on this call. Used by the memory engine to apply
the HINDSIGHT_API_DEFAULT_BANK_TEMPLATE hook on first bank creation.
Same as get_bank_profile, but also reports whether the bank was freshly
created on this call (``BankProfileResult.created``). Used by the memory
engine to apply the HINDSIGHT_API_DEFAULT_BANK_TEMPLATE hook on first bank
creation.
Returns:
Tuple of (BankProfile, created) where created is True if the bank
did not exist before this call.
Acquires its own connection. When the caller already holds a connection and
wants the bank row to share its transaction (so the lazy bank-create commits
or rolls back atomically with the caller's write), use
``get_or_create_bank_profile_on_conn`` instead.
"""
async with acquire_with_retry(pool) as conn:
# Try to get existing bank
row = await conn.fetchrow(
f"""
SELECT name, disposition, mission
FROM {fq_table("banks")} WHERE bank_id = $1
""",
bank_id,
return await get_or_create_bank_profile_on_conn(conn, bank_id, ops=pool.ops)
async def get_or_create_bank_profile_on_conn(conn, bank_id: str, *, ops) -> BankProfileResult:
"""
Connection-bound variant of ``get_or_create_bank_profile``.
Runs the SELECT, the ``INSERT ... ON CONFLICT DO NOTHING`` and the per-bank
vector index creation on the caller-supplied ``conn``. When ``conn`` is
inside an open transaction, the lazy bank-create therefore commits (or rolls
back) atomically with whatever bank-scoped write the caller performs on the
same connection closing the window where a freshly-created bank could
outlive a write that ultimately failed.
``ops`` is the backend's dialect ops object (``backend.ops``), needed for
per-bank vector index DDL.
"""
# Try to get existing bank
row = await conn.fetchrow(
f"""
SELECT name, disposition, mission
FROM {fq_table("banks")} WHERE bank_id = $1
""",
bank_id,
)
if row:
# asyncpg returns JSONB as a string, so parse it
disposition_data = row["disposition"]
if isinstance(disposition_data, str):
disposition_data = json.loads(disposition_data)
return BankProfileResult(
profile=BankProfile(
name=row["name"],
disposition=DispositionTraits(**disposition_data),
mission=row["mission"] or "",
),
created=False,
)
if row:
# asyncpg returns JSONB as a string, so parse it
disposition_data = row["disposition"]
if isinstance(disposition_data, str):
disposition_data = json.loads(disposition_data)
# 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()
inserted = await conn.fetchval(
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
json.dumps(DEFAULT_DISPOSITION),
"",
internal_id,
)
return (
BankProfile(
name=row["name"],
disposition=DispositionTraits(**disposition_data),
mission=row["mission"] or "",
),
False,
)
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)
# 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()
inserted = await conn.fetchval(
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
json.dumps(DEFAULT_DISPOSITION),
"",
internal_id,
)
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=pool.ops)
return (
BankProfile(name=bank_id, disposition=DispositionTraits(**DEFAULT_DISPOSITION), mission=""),
created,
)
return BankProfileResult(
profile=BankProfile(name=bank_id, disposition=DispositionTraits(**DEFAULT_DISPOSITION), mission=""),
created=created,
)
async def update_bank_disposition(pool, bank_id: str, disposition: dict[str, int]) -> None:
@@ -15,11 +15,23 @@ class EmbeddingsBackend(Protocol):
"""Minimal duck-typed surface used by retain/recall — the concrete `Embeddings`
ABC supplies default implementations that delegate to `encode()`."""
@property
def dimension(self) -> int: ...
def encode_query(self, texts: list[str]) -> list[list[float]]: ...
def encode_documents(self, texts: list[str]) -> list[list[float]]: ...
def _validate_embedding_vector(vector: list[float], *, index: int, expected_dimension: int) -> list[float]:
actual_dimension = len(vector)
if actual_dimension == 0:
raise RuntimeError(f"embedding {index} has dimension 0; expected {expected_dimension}")
if actual_dimension != expected_dimension:
raise RuntimeError(f"embedding {index} has dimension {actual_dimension}; expected {expected_dimension}")
return vector
def generate_embedding(
embeddings_backend: EmbeddingsBackend, text: str, input_type: EmbeddingInputType = "document"
) -> list[float]:
@@ -36,10 +48,19 @@ def generate_embedding(
"""
try:
embeddings = _encode_with_input_type(embeddings_backend, [text], input_type)
return embeddings[0]
except Exception as e:
raise Exception(f"Failed to generate embedding: {str(e)}")
if len(embeddings) != 1:
raise RuntimeError(
f"Embeddings backend returned {len(embeddings)} vectors for 1 input text; expected exact 1:1 alignment"
)
return _validate_embedding_vector(
embeddings[0],
index=0,
expected_dimension=embeddings_backend.dimension,
)
def _encode_with_input_type(
embeddings_backend: EmbeddingsBackend, texts: list[str], input_type: EmbeddingInputType
@@ -81,4 +102,7 @@ async def generate_embeddings_batch(
"expected exact 1:1 alignment"
)
return embeddings
return [
_validate_embedding_vector(embedding, index=index, expected_dimension=embeddings_backend.dimension)
for index, embedding in enumerate(embeddings)
]
@@ -10,12 +10,13 @@ import json
import logging
import re
from datetime import datetime, timedelta
from typing import Literal, cast
from typing import Any, Literal, cast
from pydantic import BaseModel, ConfigDict, Field, create_model, field_validator
from ...config import get_config
from ..llm_wrapper import LLMConfig, OutputTooLongError, sanitize_llm_output
from ..operation_metadata import RetainExtractionErrors
from ..response_models import TokenUsage
from .entity_labels import (
EntityLabelsConfig,
@@ -510,11 +511,11 @@ LANGUAGE: MANDATORY — Detect the language of the input text and produce ALL ou
FACT FORMAT - BE CONCISE
1. **what**: Core fact - concise but complete (1-2 sentences max)
2. **when**: Temporal info if mentioned. "N/A" if none. Use day name when known.
3. **where**: Location if relevant. "N/A" if none.
4. **who**: People involved with relationships. "N/A" if just general info.
5. **why**: Context/significance ONLY if important. "N/A" if obvious.
1. "what": Core fact - concise but complete (1-2 sentences max)
2. "when": Temporal info if mentioned. "N/A" if none. Use day name when known.
3. "where": Location if relevant. "N/A" if none.
4. "who": People involved with relationships. "N/A" if just general info.
5. "why": Context/significance ONLY if important. "N/A" if obvious.
CONCISENESS: Capture the essence, not every word. One good sentence beats three mediocre ones.
@@ -887,20 +888,15 @@ def _build_extraction_prompt_and_schema(config) -> tuple[str, type]:
extraction_mode = config.retain_extraction_mode
extract_causal_links = config.retain_extract_causal_links
# Build retain_mission section if set - injected before the mode-specific guidelines
# Escape braces so user-supplied text survives str.format() on the prompt template.
# The per-bank retain mission is NOT baked into this system prompt: it would
# make the prompt bank-specific and force a separate Gemini context cache per
# mission (one per bank). Instead the prompt is bank-agnostic so a single
# CachedContent serves every bank, and the mission rides in the per-request
# user message via _retain_mission_preamble(). The {retain_mission_section}
# placeholder is kept (templates still reference it) but always empty here.
from hindsight_api.engine.prompt_utils import escape_for_prompt
retain_mission = getattr(config, "retain_mission", None)
if retain_mission:
retain_mission_section = (
f"══════════════════════════════════════════════════════════════════════════\n"
f"FOCUS — What to retain for this bank\n"
f"══════════════════════════════════════════════════════════════════════════\n\n"
f"{escape_for_prompt(retain_mission)}\n\n"
)
else:
retain_mission_section = ""
retain_mission_section = ""
# Select base prompt based on extraction mode
if extraction_mode == "custom":
@@ -997,6 +993,26 @@ def _build_extraction_prompt_and_schema(config) -> tuple[str, type]:
return prompt, response_schema
def _retain_mission_preamble(config) -> str:
"""The bank's retain mission, formatted for the per-request user message.
Kept OUT of the cached system prompt (which must stay bank-agnostic so one
CachedContent serves every bank otherwise each distinct mission spawns its
own cache) and prepended to the user message instead. Returns "" when unset.
No brace-escaping needed: unlike the system template, the user message is
used verbatim, not passed through str.format().
"""
retain_mission = getattr(config, "retain_mission", None)
if not retain_mission:
return ""
return (
"══════════════════════════════════════════════════════════════════════════\n"
"FOCUS — What to retain for this bank (takes priority over the general guidelines)\n"
"══════════════════════════════════════════════════════════════════════════\n\n"
f"{retain_mission}\n\n"
)
def _build_user_message(
chunk: str,
chunk_index: int,
@@ -1005,8 +1021,14 @@ def _build_user_message(
context: str,
metadata: dict[str, str] | None = None,
agent_name: str | None = None,
mission_preamble: str = "",
) -> str:
"""Build user message for fact extraction."""
"""Build user message for fact extraction.
``mission_preamble`` (the bank's retain mission, possibly empty) is prepended
so the bank-specific focus lives in the variable user turn rather than the
cached, bank-agnostic system prompt.
"""
from .orchestrator import parse_datetime_flexible
sanitized_chunk = _sanitize_text(chunk)
@@ -1025,9 +1047,21 @@ def _build_user_message(
narrator_section = ""
if agent_name:
narrator_section = f'\nNarrator: {agent_name} (AI agent — first-person statements like "I did X" are the agent\'s own actions; classify as "assistant")'
narrator_section = (
f"\nNarrator: {agent_name} (the AI agent whose memory this is). By default, "
f'first-person statements like "I did X" are {agent_name}\'s own actions → classify as '
f'"assistant".'
)
# Only defer to the Context when one was actually provided — otherwise this
# clause points at a "Context: none" line and just adds noise.
if context:
narrator_section += (
" BUT the Context above takes precedence: if it identifies a different "
"first-person speaker (e.g. a user or customer in a transcript), attribute those "
'statements to that speaker and classify them as "world", not "assistant".'
)
return f"""Extract facts from the following text chunk.
return f"""{mission_preamble}Extract facts from the following text chunk.
Chunk: {chunk_index + 1}/{total_chunks}
Event Date: {event_date_str}
@@ -1053,12 +1087,15 @@ def _build_request_body(llm_config, config, prompt: str, user_message: str, resp
if llm_config.provider == "openai" and llm_config._provider_impl.openai_service_tier:
request_body["service_tier"] = llm_config._provider_impl.openai_service_tier
# Add response_format (JSON schema)
# Add response_format (JSON schema). The batch path builds the request body
# directly instead of going through LLMProvider.call(), so honour
# HINDSIGHT_API_LLM_STRICT_SCHEMA here too: strict=True grammar-enforces the
# output on capable backends rather than relying on the model to emit clean JSON.
if hasattr(response_schema, "model_json_schema"):
schema = response_schema.model_json_schema()
request_body["response_format"] = {
"type": "json_schema",
"json_schema": {"name": "facts", "schema": schema},
"json_schema": {"name": "facts", "schema": schema, "strict": config.llm_strict_schema},
}
return request_body
@@ -1094,8 +1131,38 @@ async def _extract_facts_from_chunk(
extraction_mode = config.retain_extraction_mode
extract_causal_links = config.retain_extract_causal_links
# Build user message using helper function
user_message = _build_user_message(chunk, chunk_index, total_chunks, event_date, context, metadata, agent_name)
# Build user message — the bank mission rides here (not in the cached prefix).
user_message = _build_user_message(
chunk,
chunk_index,
total_chunks,
event_date,
context,
metadata,
agent_name,
mission_preamble=_retain_mission_preamble(config),
)
# Opt into context caching when the provider supports it. The prompt and
# response_schema are bank-agnostic (the mission lives in the user message),
# so one cached prefix serves every bank; reusing it across many small-payload
# retain calls dramatically lowers per-call input
# cost. ``get_or_create_cached_prefix`` returns None when caching is
# disabled, unsupported, or the prefix is too small; the LLM call
# transparently falls back to the uncached path in that case.
cached_prefix_name: str | None = None
provider_impl = getattr(llm_config, "_provider_impl", None)
if provider_impl is not None and provider_impl.supports_prompt_caching():
try:
cached_prefix_name = await provider_impl.get_or_create_cached_prefix(
system_instruction=prompt,
response_schema=response_schema,
)
except Exception:
# Caching is a soft optimisation — never let a cache-side
# error block a retain operation.
logger.exception("Cache prefix lookup failed; falling back to uncached call")
cached_prefix_name = None
# Retry logic for JSON validation errors
# Use retain-specific overrides if set, otherwise fall back to global LLM config
@@ -1116,7 +1183,7 @@ async def _extract_facts_from_chunk(
config.retain_llm_max_backoff if config.retain_llm_max_backoff is not None else config.llm_max_backoff
)
extraction_response_json, call_usage = await llm_config.call(
call_kwargs: dict[str, Any] = dict(
messages=[{"role": "system", "content": prompt}, {"role": "user", "content": user_message}],
response_format=response_schema,
scope="retain_extract_facts",
@@ -1128,6 +1195,10 @@ async def _extract_facts_from_chunk(
skip_validation=True, # Get raw JSON, we'll validate leniently
return_usage=True,
)
if cached_prefix_name is not None:
call_kwargs["cached_prefix"] = cached_prefix_name
extraction_response_json, call_usage = await llm_config.call(**call_kwargs)
usage = usage + call_usage # Aggregate usage across retries
# Lenient parsing of facts from raw JSON
@@ -1640,6 +1711,39 @@ logger = logging.getLogger(__name__)
SECONDS_PER_FACT = 0.01
async def _write_batch_extraction_errors(
pool: Any,
operation_id: str | None,
schema: str | None,
errors: RetainExtractionErrors,
) -> None:
"""Persist non-fatal Batch API extraction errors into operation result_metadata."""
if not pool or not operation_id or errors.count == 0:
return
from ..db_utils import acquire_with_retry
from ..task_backend import fq_table
# `errors` is the complete set for this extraction run, so overwrite the
# extraction_errors_* keys rather than folding in what's already stored. On
# batch crash recovery the resumed batch reprocesses every result and
# recomputes `errors` from scratch; reading + merging the prior run's
# counters here would double-count them. The SQL `||` merge still preserves
# unrelated keys (e.g. batch_id) already on result_metadata.
table = fq_table("async_operations", schema)
async with acquire_with_retry(pool) as conn:
await conn.execute(
f"""
UPDATE {table}
SET result_metadata = COALESCE(result_metadata, '{{}}'::jsonb) || $2::jsonb,
updated_at = now()
WHERE operation_id = $1
""",
operation_id,
json.dumps(errors.to_dict()),
)
async def extract_facts_from_contents_batch_api(
contents: list[RetainContent],
llm_config,
@@ -1731,6 +1835,7 @@ async def extract_facts_from_contents_batch_api(
item.context,
item.metadata or None,
agent_name,
mission_preamble=_retain_mission_preamble(config),
)
# Build request body using helper function
@@ -1816,6 +1921,7 @@ async def extract_facts_from_contents_batch_api(
all_facts_from_llm = []
chunks_metadata = []
total_usage = TokenUsage()
extraction_errors = RetainExtractionErrors()
for chunk_idx, (chunk_content, content_index, chunk_index_in_content, event_date, context) in enumerate(
all_chunks_info
@@ -1824,7 +1930,9 @@ async def extract_facts_from_contents_batch_api(
result = results_by_id.get(custom_id)
if not result:
logger.warning(f"Missing result for {custom_id}, skipping")
message = f"{custom_id}: missing batch result"
logger.warning(message)
extraction_errors.add(message)
chunks_metadata.append(
ChunkMetadata(
chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx
@@ -1834,7 +1942,9 @@ async def extract_facts_from_contents_batch_api(
# Check for errors
if result.get("error"):
logger.error(f"Error in {custom_id}: {result['error']}")
message = f"{custom_id}: {result['error']}"
logger.error(f"Error in {message}")
extraction_errors.add(message)
chunks_metadata.append(
ChunkMetadata(
chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx
@@ -1847,7 +1957,9 @@ async def extract_facts_from_contents_batch_api(
choices = response_body.get("choices", [])
if not choices:
logger.warning(f"No choices in response for {custom_id}")
message = f"{custom_id}: no choices in response"
logger.warning(message)
extraction_errors.add(message)
chunks_metadata.append(
ChunkMetadata(
chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx
@@ -1862,7 +1974,9 @@ async def extract_facts_from_contents_batch_api(
try:
extraction_response_json = json.loads(content_str)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse JSON for {custom_id}: {e}")
message = f"{custom_id}: failed to parse JSON: {e}"
logger.error(message)
extraction_errors.add(message)
chunks_metadata.append(
ChunkMetadata(
chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx
@@ -2035,7 +2149,9 @@ async def extract_facts_from_contents_batch_api(
fact = Fact(fact=combined_text, fact_type=fact_type, **fact_data)
chunk_facts.append(fact)
except Exception as e:
logger.error(f"Failed to create Fact model for fact {i}: {e}")
message = f"{custom_id}: failed to create Fact model for fact {i}: {e}"
logger.error(message)
extraction_errors.add(message)
continue
all_facts_from_llm.extend(chunk_facts)
@@ -2100,6 +2216,8 @@ async def extract_facts_from_contents_batch_api(
# Step 8: Auto-tag facts from label groups with tag=True
_inject_label_tags(extracted_facts, config)
await _write_batch_extraction_errors(pool, operation_id, schema, extraction_errors)
logger.info(f"Batch API extracted {len(extracted_facts)} facts from {len(all_chunks_info)} chunks")
return extracted_facts, chunks_metadata, total_usage
@@ -147,12 +147,13 @@ async def ensure_bank_exists(conn, bank_id: str, ops=None) -> None:
internal_id = uuid.uuid4()
inserted = await conn.fetchval(
f"""
INSERT INTO {fq_table("banks")} (bank_id, disposition, mission, internal_id)
VALUES ($1, $2::jsonb, $3, $4)
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,
@@ -599,23 +599,35 @@ async def compute_semantic_links_ann(
t_query = time_mod.time()
seed_count = sum(1 for ft in fact_types if ft == fact_type)
logger.debug(f"[ANN] Querying fact_type={fact_type}: {seed_count} seeds")
# Cast each seed's text embedding to `vector` exactly once in a
# MATERIALIZED CTE. Casting inside the LATERAL (s.emb_text::vector)
# re-parses the ~5KB embedding string for every candidate row the
# probe touches — seeds × bank_units text-parses per batch, which
# dominated the whole job on small banks (see #1919: ~50 seeds over
# ~1k units took 1.5-3.7s, ~25-48x slower than casting once). The
# stable `vector` column also lets the planner consider an HNSW
# index scan, which a cast expression inhibits.
ft_rows = await conn.fetch(
f"""
WITH seeds AS MATERIALIZED (
SELECT unit_id, emb_text::vector AS emb
FROM _ann_seeds
WHERE fact_type = $2
)
SELECT s.unit_id AS from_id,
n.id::text AS to_id,
n.similarity
FROM _ann_seeds s
FROM seeds s
CROSS JOIN LATERAL (
SELECT mu.id,
1 - (mu.embedding <=> s.emb_text::vector) AS similarity
1 - (mu.embedding <=> s.emb) AS similarity
FROM {fq_table("memory_units")} mu
WHERE mu.bank_id = $1
AND mu.fact_type = $2
AND mu.embedding IS NOT NULL
ORDER BY mu.embedding <=> s.emb_text::vector
ORDER BY mu.embedding <=> s.emb
LIMIT $3
) n
WHERE s.fact_type = $2
""",
bank_id,
fact_type,
@@ -11,6 +11,7 @@ import logging
import time
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
@@ -111,6 +112,25 @@ RetainOutboxCallback = Callable[[asyncpg.Connection], Awaitable[None]]
RetainOutboxCallbackFactory = Callable[[list[RetainContentDict]], RetainOutboxCallback | None]
def _resolve_narrator(profile_name: str, bank_id: str) -> str | None:
"""Resolve the narrator (memory owner) used to prime fact extraction.
The narrator is injected as a "Narrator: {name}" line in fact extraction and
is stamped into the who-dimension of every first-person fact and the
observations later consolidated from those facts. That is correct for a named
agent retaining its own logs, but harmful when ``name`` is just the bank_id:
on auto-create the bank ``name`` defaults to ``bank_id``, which is typically a
routing key (e.g. ``my-agent::channel-456::user-789``), not a speaker. Priming
extraction with a routing key embeds that string into stored fact text and
pollutes downstream observations (issue #1680). Suppress it in that case.
Returns the narrator name, or ``None`` to omit the Narrator line entirely.
"""
if profile_name == bank_id:
return None
return profile_name
def _build_retain_params(contents_dicts, document_tags=None, doc_contents=None):
"""Build retain_params and merged_tags from content dicts."""
if doc_contents is not None:
@@ -404,6 +424,7 @@ async def retain_batch(
db_semaphore: "asyncio.Semaphore | None" = None,
document_body_override: str | None = None,
chunk_index_offset: int = 0,
progress_callback: "Callable[..., Awaitable[None]] | None" = None,
) -> tuple[list[list[str]], TokenUsage, int | None]:
"""
Process a batch of content through the retain pipeline.
@@ -439,7 +460,9 @@ async def retain_batch(
# Get bank profile
profile = await bank_utils.get_bank_profile(pool, bank_id)
agent_name = profile["name"]
# Suppress the narrator when name == bank_id (auto-create default) — see
# _resolve_narrator for why a routing-key narrator pollutes extraction (#1680).
agent_name = _resolve_narrator(profile["name"], bank_id)
# Convert dicts to RetainContent objects
contents = _build_contents(contents_dicts, document_tags)
@@ -692,6 +715,7 @@ async def retain_batch(
db_semaphore=db_semaphore,
document_body_override=document_body_override,
chunk_index_offset=chunk_index_offset,
progress_callback=progress_callback,
)
@@ -831,6 +855,7 @@ async def _streaming_retain_batch(
db_semaphore: "asyncio.Semaphore | None" = None,
document_body_override: str | None = None,
chunk_index_offset: int = 0,
progress_callback: "Callable[..., Awaitable[None]] | None" = None,
) -> tuple[list[list[str]], TokenUsage]:
"""
Process a large document in streaming mini-batches to bound memory usage.
@@ -952,19 +977,29 @@ async def _streaming_retain_batch(
tags=source.tags,
observation_scopes=source.observation_scopes,
)
extracted, processed, chunk_meta, usage = await _extract_and_embed(
[content],
llm_config,
agent_name,
config,
embeddings_model,
format_date_fn,
fact_type_override,
log_buffer,
pool,
operation_id,
schema,
)
# Attribute this chunk's extraction LLM call to its document, so the
# trace row carries document_id (a document accrues one such trace
# per retain/re-retain). Per-call: the operation-level trace context
# is shared across a batch's documents.
from ..llm_trace import reset_call_metadata, set_call_metadata
meta_token = set_call_metadata({"document_id": effective_doc_id})
try:
extracted, processed, chunk_meta, usage = await _extract_and_embed(
[content],
llm_config,
agent_name,
config,
embeddings_model,
format_date_fn,
fact_type_override,
log_buffer,
pool,
operation_id,
schema,
)
finally:
reset_call_metadata(meta_token)
await chunk_queue.put((global_idx, content, extracted, processed, chunk_meta, usage))
# Memory: release the chunk text from the shared list now that it's
# been extracted and queued. The queued RetainContent holds its own copy.
@@ -999,6 +1034,25 @@ async def _streaming_retain_batch(
async def _db_consumer() -> None:
batch: list[tuple] = []
consumer_batch_idx = 0
chunks_committed = 0
# Best-effort durable progress: how many chunks of this document have been
# extracted+committed so far. Written per consumer batch so an operator polling
# the retain operation sees "storing 200/1200 chunks" advancing instead of a
# single opaque sub-batch tick. Never lets a heartbeat failure break retain.
async def _emit_chunk_progress() -> None:
if not (progress_callback and operation_id):
return
try:
await progress_callback(
operation_id,
stage="storing",
processed=chunks_committed,
total=total_chunks,
detail={"facts_committed": len(all_unit_ids)},
)
except Exception:
logger.debug("retain chunk-progress write failed", exc_info=True)
while True:
item = await chunk_queue.get()
@@ -1010,6 +1064,8 @@ async def _streaming_retain_batch(
consumer_batch_idx,
is_last=True,
)
chunks_committed += len(batch)
await _emit_chunk_progress()
break
batch.append(item)
@@ -1029,6 +1085,8 @@ async def _streaming_retain_batch(
is_last=False,
)
consumer_batch_idx += 1
chunks_committed += len(batch)
await _emit_chunk_progress()
batch = []
async def _process_db_batch(
@@ -1181,20 +1239,17 @@ async def _streaming_retain_batch(
async with acquire_with_retry(pool) as conn:
async with conn.transaction():
# --- Document ownership gate ---
# Lock the document row to serialize all concurrent writers.
# SELECT ... FOR UPDATE doesn't lock non-existent rows, so we
# first ensure the row exists with a lightweight upsert, THEN lock it.
# The content_hash='__pending__' placeholder is immediately overwritten
# by handle_document_tracking or upsert_document_metadata below.
await conn.execute(
f"INSERT INTO {fq_table('documents')} (id, bank_id, original_text, content_hash) "
f"VALUES ($1, $2, '', '__pending__') "
f"ON CONFLICT (id, bank_id) DO NOTHING",
effective_doc_id,
bank_id,
)
existing_hash = await conn.fetchval(
f"SELECT content_hash FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2 FOR UPDATE",
# Ensure the document row exists, lock it to serialize all
# concurrent same-document writers, and read its pre-existing
# hash. The lock prevents interleaved retains from corrupting
# each other in handle_document_tracking; the returned hash
# ('__pending__' for a freshly inserted row) drives the
# takeover check for later batches below. The PG/Oracle split
# lives in the ops layer because Oracle can't do this upsert +
# RETURNING in a single statement.
existing_hash = await pool.ops.lock_document_for_write(
conn,
fq_table("documents"),
effective_doc_id,
bank_id,
)
@@ -1501,6 +1556,35 @@ async def _streaming_retain_batch(
# ---------------------------------------------------------------------------
@dataclass
class _ChunkDiff:
"""Classification of chunk indices when diffing new content vs stored chunks."""
unchanged: list[int]
changed: list[int]
new: list[int]
removed: list[int]
def _classify_chunk_diff(existing_by_index: dict[int, Any], new_hashes: dict[int, str]) -> _ChunkDiff:
"""Classify chunk indices by comparing freshly computed ``new_hashes``
(index -> content hash) against the currently stored chunks
(``existing_by_index``: index -> chunk row)."""
diff = _ChunkDiff(unchanged=[], changed=[], new=[], removed=[])
for idx, new_hash in new_hashes.items():
existing = existing_by_index.get(idx)
if existing and existing.content_hash == new_hash:
diff.unchanged.append(idx)
elif existing:
diff.changed.append(idx)
else:
diff.new.append(idx)
for idx in existing_by_index:
if idx not in new_hashes:
diff.removed.append(idx)
return diff
async def _try_delta_retain(
pool: Any,
embeddings_model,
@@ -1570,18 +1654,11 @@ async def _try_delta_retain(
existing_by_index = {c.chunk_index: c for c in existing_chunks}
new_hashes = {idx: chunk_storage.compute_chunk_hash(text) for idx, text in new_chunks_with_contents.items()}
unchanged_indices, changed_indices, new_indices, removed_indices = [], [], [], []
for idx, new_hash in new_hashes.items():
existing = existing_by_index.get(idx)
if existing and existing.content_hash == new_hash:
unchanged_indices.append(idx)
elif existing:
changed_indices.append(idx)
else:
new_indices.append(idx)
for idx in existing_by_index:
if idx not in new_hashes:
removed_indices.append(idx)
diff = _classify_chunk_diff(existing_by_index, new_hashes)
unchanged_indices = diff.unchanged
changed_indices = diff.changed
new_indices = diff.new
removed_indices = diff.removed
log_buffer.append(
f"[delta] Chunk diff: {len(unchanged_indices)} unchanged, "
@@ -1628,20 +1705,85 @@ async def _try_delta_retain(
document_body_override=document_body_override,
)
# Extract facts and generate embeddings (shared pipeline)
extracted_facts, processed_facts, new_chunk_metadata, usage = await _extract_and_embed(
delta_contents,
llm_config,
agent_name,
config,
embeddings_model,
format_date_fn,
fact_type_override,
log_buffer,
pool,
operation_id,
schema,
)
# Freshness recheck BEFORE the (expensive) LLM extraction.
#
# We snapshotted the document hash and chunks outside any lock. A concurrent
# retain for the same document may have committed a new version while we were
# chunking and diffing. Re-read the current hash; if it changed, recompute the
# diff against the now-committed chunk state. If the concurrent writer already
# produced content identical to ours, there is nothing left to extract — skip
# the LLM call entirely (metadata-only). If it still differs, fall back to the
# streaming path (which dedups per-chunk and re-locks the document).
#
# This narrows — but cannot fully close — the race window: a writer can still
# commit during our extraction. The post-extraction hash gate inside the write
# transaction remains the correctness backstop; this check exists purely to
# avoid burning LLM tokens on work a concurrent request already did.
async with acquire_with_retry(pool) as conn:
recheck_hash = await conn.fetchval(
f"SELECT content_hash FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2",
effective_doc_id,
bank_id,
)
if recheck_hash is not None and doc_hash_at_load is not None and recheck_hash != doc_hash_at_load:
log_buffer.append(
f"[delta] Document {effective_doc_id} changed before extraction "
f"(concurrent retain) — rechecking diff against current state"
)
async with acquire_with_retry(pool) as conn:
current_chunks = await chunk_storage.load_existing_chunks(conn, bank_id, effective_doc_id)
if not current_chunks or any(c.content_hash is None for c in current_chunks):
log_buffer.append("[delta] Recheck: current chunks unavailable — falling back to full retain")
logger.info("\n" + "\n".join(log_buffer) + "\n")
return None
current_by_index = {c.chunk_index: c for c in current_chunks}
recheck = _classify_chunk_diff(current_by_index, new_hashes)
if not (recheck.changed or recheck.new or recheck.removed):
log_buffer.append(
"[delta] Recheck: concurrent retain already stored identical content — "
"skipping extraction, updating metadata only"
)
return await _delta_metadata_only(
pool,
bank_id,
contents_dicts,
contents,
effective_doc_id,
document_tags,
log_buffer,
start_time,
outbox_callback,
document_body_override=document_body_override,
)
log_buffer.append(
f"[delta] Recheck: {len(recheck.changed) + len(recheck.new) + len(recheck.removed)} chunks still differ — "
f"falling back to full retain"
)
logger.info("\n" + "\n".join(log_buffer) + "\n")
return None
# Extract facts and generate embeddings (shared pipeline). Attribute these
# extraction calls to the document so the delta re-retain's trace also binds
# to it (a document accrues one trace per full/delta retain).
from ..llm_trace import reset_call_metadata, set_call_metadata
meta_token = set_call_metadata({"document_id": effective_doc_id})
try:
extracted_facts, processed_facts, new_chunk_metadata, usage = await _extract_and_embed(
delta_contents,
llm_config,
agent_name,
config,
embeddings_model,
format_date_fn,
fact_type_override,
log_buffer,
pool,
operation_id,
schema,
)
finally:
reset_call_metadata(meta_token)
# Database transaction
result_unit_ids: list[list[str]] = []
@@ -7,6 +7,27 @@ from typing import Any
from .types import MergedCandidate, RetrievalResult
def cap_per_source(results: list[RetrievalResult], cap: int) -> list[RetrievalResult]:
"""Truncate a single retrieval arm to its top-``cap`` results.
Applied per source (semantic, BM25, graph, temporal) before fusion so that
one over-expanding backend cannot crowd out the others when the merged pool
is later trimmed to the reranker's global candidate budget. The caller is
responsible for sorting ``results`` by relevance first; this only slices.
Args:
results: Results for a single source, already sorted best-first.
cap: Maximum results to keep. ``0`` (or negative) disables the cap.
Returns:
The original list when the cap is disabled or not exceeded, otherwise a
truncated copy of the top ``cap`` results.
"""
if cap <= 0 or len(results) <= cap:
return results
return results[:cap]
def reciprocal_rank_fusion(result_lists: list[list[RetrievalResult]], k: int = 60) -> list[MergedCandidate]:
"""
Merge multiple ranked result lists using Reciprocal Rank Fusion.
@@ -77,6 +98,66 @@ def reciprocal_rank_fusion(result_lists: list[list[RetrievalResult]], k: int = 6
return merged_results
def interleave_fusion(result_lists: list[list[RetrievalResult]]) -> list[MergedCandidate]:
"""Round-robin (interleaved) fusion — an alternative to RRF for dedup-style recall.
RRF scores a doc by the *sum* of its reciprocal ranks across arms, so a result
that is #1 in one arm but absent/low in the others gets averaged down. That is
exactly the consolidation-dedup failure mode: the near-identical existing
observation (the "twin" to merge into) is semantic rank #1, yet shares no
source-fact graph link and little lexical overlap, so RRF drops it below the
recall budget cutoff and the LLM never sees it creates a duplicate.
Interleave instead *guarantees every arm's top hits a slot*: take each arm's
#1, then each arm's #2, … in arm-priority order, de-duplicating, until all
results are placed. The arm priority is the order of ``result_lists``
(semantic, bm25, graph, temporal), so semantic #1 is always first.
``rrf_score`` is assigned strictly decreasing by final interleave position so
downstream order-by-score sorts preserve the interleave order; ``source_ranks``
mirrors the RRF bookkeeping (each doc's rank within every arm it appears in).
"""
source_names = ["semantic", "bm25", "graph", "temporal"]
source_ranks: dict[str, dict[str, int]] = {}
all_retrievals: dict[str, RetrievalResult] = {}
for source_idx, results in enumerate(result_lists):
source_name = source_names[source_idx] if source_idx < len(source_names) else f"source_{source_idx}"
for rank, retrieval in enumerate(results, start=1):
if not isinstance(retrieval, RetrievalResult):
raise TypeError(
f"Expected RetrievalResult but got {type(retrieval).__name__} in {source_name} results at rank {rank}"
)
doc_id = retrieval.id
all_retrievals.setdefault(doc_id, retrieval)
source_ranks.setdefault(doc_id, {})[f"{source_name}_rank"] = rank
# Round-robin pick across arms in priority order: all #1s, then all #2s, ...
ordered_ids: list[str] = []
seen: set[str] = set()
max_len = max((len(r) for r in result_lists), default=0)
for r in range(max_len):
for results in result_lists:
if r < len(results):
doc_id = results[r].id
if doc_id not in seen:
seen.add(doc_id)
ordered_ids.append(doc_id)
n = len(ordered_ids)
return [
MergedCandidate(
retrieval=all_retrievals[doc_id],
# Strictly decreasing by interleave position → sorting desc by rrf_score
# reproduces the interleave order downstream.
rrf_score=float(n - pos),
rrf_rank=pos + 1,
source_ranks=source_ranks[doc_id],
)
for pos, doc_id in enumerate(ordered_ids)
]
def normalize_scores_on_deltas(results: list[dict[str, Any]], score_keys: list[str]) -> list[dict[str, Any]]:
"""
Normalize scores based on deltas (min-max normalization within result set).
@@ -0,0 +1,117 @@
"""Per-strategy recall boosting.
A deployment can prioritise one retrieval arm (semantic, bm25, graph, temporal)
over the others via ``HINDSIGHT_API_RECALL_STRATEGY_BOOSTS``, expressed as a
human priority *level* rather than an opaque number e.g. ``graph:high`` to
strongly favour graph hits.
A level is chosen instead of a raw weight because the boost is applied in two
structurally different places that live on different score scales, so a single
number could not mean the same thing in both. The level maps to a tuned
:class:`BoostWeights` pair:
1. **Before the reranker cap** :func:`boosted_rrf_score` uses ``BoostWeights.rrf``
as a weighted-RRF multiplier on the boosted arm's rank contribution, so its
candidates survive the global reranker candidate budget instead of being
trimmed by raw RRF score. Rank-aware: a candidate ranked #1 in the boosted
arm is protected more than one ranked #200.
2. **After the reranker** :func:`additive_strategy_boost` uses
``BoostWeights.additive`` as a flat bump to the final ranking weight (which
sits in ~[0, 1] after cross-encoder + recency/temporal scoring), nudging the
boosted arm's candidates up the final ordering.
Both functions are no-ops when ``boosts`` is empty, preserving current behaviour.
"""
from dataclasses import dataclass
from .types import MergedCandidate
@dataclass(frozen=True)
class BoostWeights:
"""Per-stage boost magnitudes for one priority level.
The two fields live on different scales on purpose (see module docstring):
``rrf`` multiplies an arm's ``1/(k+rank)`` RRF contribution; ``additive`` is
added directly to the post-rerank weight in ~[0, 1].
"""
rrf: float
additive: float
# Priority level -> per-stage boost magnitudes. Tuned against real recall traces
# (LoCoMo bank, 336 merged candidates → 300-cap, local ms-marco cross-encoder):
#
# Stage 1 (rrf, weighted-RRF multiplier on the arm's 1/(k+rank) contribution).
# The observed 300-cap boundary RRF score was ~0.0055; a graph-only candidate
# falls below it past graph-rank ~120. The multipliers map to that boundary:
# low=1.0 doubles the arm's vote — rescues at-risk candidates from the cut
# (graph-rank 150: 0.0048 → 0.0095) without reshuffling much.
# medium=3.0 promotes them into the middle of the pool (~rank 60).
# high=6.0 makes the boosted arm dominate the top of the candidate pool.
#
# Stage 2 (additive, flat bump to the post-rerank weight in [0, 1]). The local
# cross-encoder is sharply bimodal: strong direct matches score 0.50.999, while
# everything else — including graph hits the CE undervalues, which is exactly
# what we boost — collapses near 0. So the additive lifts a ~0 candidate up the
# weight scale. Levels are calibrated as relevance thresholds it can outrank:
# low=0.05 nudges above the near-0 tail; loses to any real CE match.
# medium=0.2 competes with weak/moderate matches.
# high=0.5 wins over most semantic matches (honouring "prioritise graph over
# semantic"); only a strong direct match (>0.5 normalized) still wins.
#
# The keys are the user-facing contract; config.py validates env input against
# them (kept in sync by a guard test).
BOOST_LEVELS: dict[str, BoostWeights] = {
"low": BoostWeights(rrf=1.0, additive=0.05),
"medium": BoostWeights(rrf=3.0, additive=0.2),
"high": BoostWeights(rrf=6.0, additive=0.5),
}
def boosted_rrf_score(candidate: MergedCandidate, boosts: dict[str, str], k: int = 60) -> float:
"""Return ``candidate``'s RRF score plus a weighted-RRF boost delta.
For each boosted arm the candidate appeared in, adds ``level.rrf * 1/(k+rank)``
i.e. scales that arm's RRF contribution by the level's multiplier. Staying
in RRF units keeps the boost comparable to the base score and rank-aware.
Args:
candidate: Merged candidate carrying ``rrf_score`` and ``source_ranks``.
boosts: Map of strategy name -> priority level. Empty means no boost.
k: RRF constant; must match the value used during fusion.
Returns:
The (possibly) boosted score to sort by. Equal to ``rrf_score`` when no
boosted arm surfaced this candidate.
"""
if not boosts:
return candidate.rrf_score
delta = 0.0
for strategy, level in boosts.items():
rank = candidate.source_ranks.get(f"{strategy}_rank")
if rank is not None:
delta += BOOST_LEVELS[level].rrf * (1.0 / (k + rank))
return candidate.rrf_score + delta
def additive_strategy_boost(source_ranks: dict[str, int], boosts: dict[str, str]) -> float:
"""Return the flat additive boost for a candidate given its source ranks.
Sums the ``additive`` magnitude of every boosted arm that surfaced the
candidate. Flat by design: the bump does not depend on the candidate's rank
within the arm, matching the post-rerank "additive boost" semantics.
Args:
source_ranks: ``{"graph_rank": 3, "semantic_rank": 50, ...}`` from RRF.
boosts: Map of strategy name -> priority level. Empty means no boost.
Returns:
The additive boost (0.0 when no boosted arm surfaced this candidate).
"""
if not boosts:
return 0.0
return sum(BOOST_LEVELS[level].additive for strategy, level in boosts.items() if f"{strategy}_rank" in source_ranks)
@@ -160,13 +160,29 @@ class CrossEncoderReranker:
import asyncio
from hindsight_api.config import ENV_MODEL_INIT_TIMEOUT, get_config
cross_encoder = self.cross_encoder
# For local providers, run in thread pool to avoid blocking event loop
if cross_encoder.provider_name == "local":
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: asyncio.run(cross_encoder.initialize()))
init = loop.run_in_executor(None, lambda: asyncio.run(cross_encoder.initialize()))
else:
await cross_encoder.initialize()
init = cross_encoder.initialize()
# Cap lazy init with the same wall-clock timeout used at startup so a
# hung model download surfaces as a clear error on the request that
# triggered it, rather than hanging the caller forever.
init_timeout = get_config().model_init_timeout
try:
await asyncio.wait_for(init, timeout=init_timeout)
except TimeoutError as e:
raise RuntimeError(
f"Cross-encoder initialization did not complete within {init_timeout:g}s. "
f"The reranker model is likely blocked loading — e.g. an offline model "
f"download. Increase {ENV_MODEL_INIT_TIMEOUT} if the first-time download "
f"legitimately needs more time."
) from e
self._initialized = True
async def rerank(self, query: str, candidates: list[MergedCandidate]) -> list[ScoredResult]:
@@ -137,6 +137,7 @@ async def retrieve_semantic_bm25_combined(
"""
result_dict: dict[str, tuple[list[RetrievalResult], list[RetrievalResult]]] = {ft: ([], []) for ft in fact_types}
config = get_config()
tokens = tokenize_query(query_text)
# Over-fetch for HNSW approximation; semantic results trimmed to limit in Python.
@@ -148,8 +149,6 @@ async def retrieve_semantic_bm25_combined(
)
table = fq_table("memory_units")
config = get_config()
# Use the SQL dialect to build backend-specific query arms, avoiding
# inline if/else branches for each database.
# Use getattr for backward compat: raw asyncpg connections (used in some
@@ -201,6 +200,7 @@ async def retrieve_semantic_bm25_combined(
embedding_param="$1",
bank_id_param="$2",
fetch_limit=hnsw_fetch,
min_similarity=config.semantic_min_similarity,
tags_clause=tags_clause,
groups_clause=groups_clause,
extra_where=created_range_clause,
@@ -226,6 +226,7 @@ async def retrieve_semantic_bm25_combined(
arm_index=i,
text_search_extension=text_ext,
bm25_language=config.text_search_extension_native_language,
bm25_min_score=config.bm25_min_score,
extra_where=created_range_clause,
)
)
@@ -273,6 +274,7 @@ async def retrieve_semantic_bm25_combined(
embedding_param="$1",
bank_id_param="$2",
fetch_limit=hnsw_fetch,
min_similarity=config.semantic_min_similarity,
tags_clause=fb_tags_clause,
groups_clause=fb_groups_clause,
extra_where=fb_created_clause,
@@ -307,6 +309,66 @@ async def retrieve_semantic_bm25_combined(
return result_dict
# Temporal entry-point selection tuning.
_TEMPORAL_POOL_SIZE = 60 # ANN candidates fetched per fact_type before coverage selection
_TEMPORAL_ENTRY_POINTS = 10 # entry points kept per fact_type after coverage selection
_TEMPORAL_COVERAGE_BUCKETS = 8 # time-buckets the window is divided into for coverage
def _coalesce_date(row: Any) -> datetime | None:
"""The unit's effective time — matches COALESCE(occurred_start, mentioned_at, occurred_end)."""
return row["occurred_start"] or row["mentioned_at"] or row["occurred_end"]
def _select_with_temporal_coverage(
pool: list,
start_date: datetime,
end_date: datetime,
limit: int,
n_buckets: int,
) -> list:
"""Pick `limit` entry points from a similarity-ranked pool, spread across the window.
The window [start_date, end_date] is split into `n_buckets` equal time-buckets.
Candidates are taken round-robin across the buckets that contain them the
best-similarity item from each populated bucket first, then the second-best from each,
and so on so every populated slice of the window is represented before any slice
contributes a second item. Within a tier, higher-similarity items lead. When the
in-window dates are degenerate (all in one bucket e.g. a batch stamped with a single
date) this collapses to plain similarity order.
"""
if len(pool) <= limit:
return list(pool)
ranked = sorted(pool, key=lambda r: r["similarity"], reverse=True)
span = (end_date - start_date).total_seconds()
def _bucket(row: Any) -> int:
d = _coalesce_date(row)
if d is None or span <= 0:
return 0
if d.tzinfo is None:
d = d.replace(tzinfo=UTC)
frac = (d - start_date).total_seconds() / span
return max(0, min(int(frac * n_buckets), n_buckets - 1))
buckets: dict[int, list] = {}
for row in ranked: # ranked is similarity-desc, so each bucket list inherits that order
buckets.setdefault(_bucket(row), []).append(row)
selected: list = []
tier = 0
while len(selected) < limit and any(len(b) > tier for b in buckets.values()):
# The tier-th best item from every bucket that still has one, strongest first.
tier_rows = [b[tier] for b in buckets.values() if len(b) > tier]
tier_rows.sort(key=lambda r: r["similarity"], reverse=True)
for row in tier_rows:
if len(selected) < limit:
selected.append(row)
tier += 1
return selected
async def retrieve_temporal_combined(
conn,
query_emb_str: str,
@@ -350,9 +412,12 @@ async def retrieve_temporal_combined(
end_date = end_date.replace(tzinfo=UTC)
# Build tags clause
# Entry point query: fixed params are $1-$6, tags at $7
tags_clause = build_tags_where_clause_simple(tags, 7, match=tags_match)
tag_groups_param_start = 7 + (1 if tags else 0)
# Entry-point query: fixed params are $1-$5 (emb, bank, start, end, threshold), tags at $6.
# fact_type is inlined as a literal per UNION ALL arm (not a bind) — this avoids `unnest`,
# which has no Oracle equivalent (the `<=>` operator and LIMIT are translated to Oracle by
# the backend on execute, but `unnest` is not). Mirrors retrieve_semantic_bm25_combined.
tags_clause = build_tags_where_clause_simple(tags, 6, match=tags_match)
tag_groups_param_start = 6 + (1 if tags else 0)
groups_clause, groups_params, _ = build_tag_groups_where_clause(tag_groups, tag_groups_param_start)
# created_at time range filter (after tags/groups)
@@ -368,69 +433,88 @@ async def retrieve_temporal_combined(
created_range_clause += f" AND updated_at < ${_next_idx}"
_next_idx += 1
params: list = [query_emb_str, bank_id, fact_types, start_date, end_date, semantic_threshold]
params: list = [query_emb_str, bank_id, start_date, end_date, semantic_threshold]
if tags:
params.append(tags)
params.extend(groups_params)
params.extend(created_range_params)
# Two-phase entry point query:
# Phase 1 (date_ranked): rank by date only — no embedding computation — for all units in
# the temporal window. This lets the planner use date indexes for filtering.
# Phase 2 (sim_ranked): join back to memory_units for only the top-50-per-type candidates
# and compute embedding similarity for that small set (≤ 50 × len(fact_types) rows).
# This avoids computing embedding distances for potentially thousands of date-range rows.
entry_points = await conn.fetch(
f"""
WITH date_ranked AS MATERIALIZED (
SELECT id, fact_type,
ROW_NUMBER() OVER (
PARTITION BY fact_type
ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC NULLS LAST
) AS rn
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND fact_type = ANY($3)
AND embedding IS NOT NULL
AND (
(occurred_start IS NOT NULL AND occurred_end IS NOT NULL
AND occurred_start <= $5 AND occurred_end >= $4)
OR
(mentioned_at IS NOT NULL AND mentioned_at BETWEEN $4 AND $5)
OR
(occurred_start IS NOT NULL AND occurred_start BETWEEN $4 AND $5)
OR
(occurred_end IS NOT NULL AND occurred_end BETWEEN $4 AND $5)
)
{tags_clause}
{groups_clause}
{created_range_clause}
),
sim_ranked AS (
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.proof_count, mu.document_id, mu.chunk_id, mu.tags, mu.metadata,
1 - (mu.embedding <=> $1::vector) AS similarity,
ROW_NUMBER() OVER (PARTITION BY mu.fact_type ORDER BY mu.embedding <=> $1::vector) AS sim_rn
FROM date_ranked dr
JOIN {fq_table("memory_units")} mu ON mu.id = dr.id
WHERE dr.rn <= 50
AND (1 - (mu.embedding <=> $1::vector)) >= $6
)
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, proof_count, document_id, chunk_id, tags, metadata, similarity
FROM sim_ranked
WHERE sim_rn <= 10
""",
*params,
)
# Entry-point selection: similarity-gated, window-filtered, then narrowed for coverage.
#
# For each fact_type, ANN-rank the units whose time overlaps the window
# (ORDER BY embedding <=> query) and keep a pool of the most relevant
# (_TEMPORAL_POOL_SIZE). The planner serves this from the per-(bank, fact_type) vector
# index when the window is broad — the dense-metadata case, where the window matches
# most rows — and from the partial date indexes plus an exact sort when the window is
# narrow. Either way the work is bounded; neither path is a scan-and-sort of the whole
# match set.
#
# Selecting by *similarity* (not recency) is deliberate. The earlier form ranked the
# entire match set by COALESCE(occurred_start, mentioned_at, occurred_end) and kept the
# 50 most recent: that biased results toward the end of the window and, on banks with
# dense/near-uniform dates (e.g. a retain batch stamped with one date), the date key was
# degenerate so the "50 most recent" became a near-random sample that could drop the
# single most relevant in-window memory — and it degraded to a full scan + disk-spilling
# sort (30s+ on a 660k-row bank). The pool is then narrowed to _TEMPORAL_ENTRY_POINTS per
# fact_type by _select_with_temporal_coverage so the entry points span the window's range
# rather than clustering in one slice.
if not fact_types:
return {}
if not entry_points:
# One similarity-ranked, window-filtered arm per fact_type, UNION ALL'd — each arm has its
# own ORDER BY ... LIMIT so the per-(bank, fact_type) vector index can serve it. fact_type
# is inlined as a literal (controlled internal enum, never user input), matching
# retrieve_semantic_bm25_combined; this keeps the query free of `unnest`/LATERAL, which the
# Oracle backend cannot translate.
pool_cols = (
"id, text, context, event_date, occurred_start, occurred_end, mentioned_at, "
"fact_type, proof_count, document_id, chunk_id, tags, metadata"
)
table = fq_table("memory_units")
arms = [
f"""(
SELECT {pool_cols}, 1 - (embedding <=> $1::vector) AS similarity
FROM {table}
WHERE bank_id = $2
AND fact_type = '{ft}'
AND embedding IS NOT NULL
AND (
(occurred_start IS NOT NULL AND occurred_end IS NOT NULL
AND occurred_start <= $4 AND occurred_end >= $3)
OR
(mentioned_at IS NOT NULL AND mentioned_at BETWEEN $3 AND $4)
OR
(occurred_start IS NOT NULL AND occurred_start BETWEEN $3 AND $4)
OR
(occurred_end IS NOT NULL AND occurred_end BETWEEN $3 AND $4)
)
AND (1 - (embedding <=> $1::vector)) >= $5
{tags_clause}
{groups_clause}
{created_range_clause}
ORDER BY embedding <=> $1::vector
LIMIT {_TEMPORAL_POOL_SIZE}
)"""
for ft in fact_types
]
pool_rows = await conn.fetch("\nUNION ALL\n".join(arms), *params)
if not pool_rows:
return {ft: [] for ft in fact_types}
# Group entry points by fact type
entries_by_ft: dict[str, list] = {ft: [] for ft in fact_types}
for ep in entry_points:
ft = ep["fact_type"]
if ft in entries_by_ft:
entries_by_ft[ft].append(ep)
# Group the ANN pool by fact type, then narrow each to coverage-spread entry points.
pool_by_ft: dict[str, list] = {ft: [] for ft in fact_types}
for row in pool_rows:
ft = row["fact_type"]
if ft in pool_by_ft:
pool_by_ft[ft].append(row)
entries_by_ft: dict[str, list] = {
ft: _select_with_temporal_coverage(
rows, start_date, end_date, _TEMPORAL_ENTRY_POINTS, _TEMPORAL_COVERAGE_BUCKETS
)
for ft, rows in pool_by_ft.items()
}
# Calculate shared temporal parameters
total_days = (end_date - start_date).total_seconds() / 86400
@@ -498,7 +582,13 @@ async def retrieve_temporal_combined(
tag_groups, spreading_groups_param_start, table_alias="mu."
)
while frontier and budget_remaining > 0 and iteration < max_iterations:
# Multi-hop temporal spreading expands a batch of seed ids with
# ``FROM unnest($2::uuid[])``, which has no Oracle equivalent. On backends
# without unnest, skip the spread: the temporal entry points are still
# returned above, and the semantic/keyword/graph retrievers cover the rest.
supports_unnest = getattr(conn, "backend_type", "postgresql") != "oracle"
while frontier and budget_remaining > 0 and iteration < max_iterations and supports_unnest:
iteration += 1
batch_ids = frontier[:batch_size]
frontier = frontier[batch_size:]
@@ -358,12 +358,15 @@ class SearchTracer:
"""
self.rrf_merged = []
for rank, (doc_id, data, rrf_meta) in enumerate(merged_results, start=1):
source_ranks = rrf_meta.get("source_ranks")
if source_ranks is None:
source_ranks = {key: value for key, value in rrf_meta.items() if key.endswith("_rank")}
self.rrf_merged.append(
RRFMergeResult(
node_id=doc_id,
text=data.get("text", ""),
rrf_score=rrf_meta.get("rrf_score", 0.0),
source_ranks=rrf_meta.get("source_ranks", {}),
source_ranks=source_ranks,
final_rrf_rank=rank,
)
)
@@ -371,6 +371,7 @@ class SQLDialect(ABC):
embedding_param: str,
bank_id_param: str,
fetch_limit: int,
min_similarity: float,
tags_clause: str = "",
groups_clause: str = "",
extra_where: str = "",
@@ -387,6 +388,7 @@ class SQLDialect(ABC):
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).
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.
extra_where: Optional additional WHERE clause fragment (e.g. time range filter).
@@ -408,6 +410,7 @@ class SQLDialect(ABC):
arm_index: int = 0,
text_search_extension: str = "native",
bm25_language: str = "english",
bm25_min_score: float = 0.0,
extra_where: str = "",
) -> str:
"""Build a BM25/full-text search subquery arm.
@@ -430,6 +433,11 @@ class SQLDialect(ABC):
"pg_textsearch", "pgroonga"). Only relevant for PostgreSQL.
bm25_language: PostgreSQL text search dictionary used by the native
backend (e.g. "english", "french"). Ignored by other backends.
bm25_min_score: Minimum BM25 relevance score a row must exceed to be
returned. Gates out non-matching rows on backends whose
operator (e.g. VectorChord) ranks every document instead
of pre-filtering to query-term matches. Backends that
already apply a boolean match gate ignore this.
extra_where: Optional additional WHERE clause fragment (e.g. time range filter).
"""
...
@@ -234,6 +234,7 @@ class OracleDialect(SQLDialect):
embedding_param: str,
bank_id_param: str,
fetch_limit: int,
min_similarity: float,
tags_clause: str = "",
groups_clause: str = "",
extra_where: str = "",
@@ -249,7 +250,7 @@ class OracleDialect(SQLDialect):
f" WHERE bank_id = {bank_id_param}"
f" AND fact_type = '{fact_type}'"
f" AND embedding IS NOT NULL"
f" AND (1 - VECTOR_DISTANCE(embedding, {embedding_param}, COSINE)) >= 0.3"
f" AND (1 - VECTOR_DISTANCE(embedding, {embedding_param}, COSINE)) >= {min_similarity}"
f" {tags_clause}"
f" {groups_clause}"
f" {extra_where}"
@@ -271,6 +272,7 @@ class OracleDialect(SQLDialect):
arm_index: int = 0,
text_search_extension: str = "native",
bm25_language: str = "english",
bm25_min_score: float = 0.0,
extra_where: str = "",
) -> str:
# Oracle Text: CONTAINS() / SCORE() with the CTXSYS.CONTEXT index.
@@ -285,7 +287,9 @@ class OracleDialect(SQLDialect):
f" FROM {table}"
f" WHERE bank_id = {bank_id_param}"
f" AND fact_type = '{fact_type}'"
f" AND CONTAINS(text, {text_param}, {label}) > 0"
# CONTAINS already gates to genuine matches; the configurable floor
# (default 0) keeps the threshold semantics uniform across backends.
f" AND CONTAINS(text, {text_param}, {label}) > {bm25_min_score:g}"
f" {tags_clause}"
f" {groups_clause}"
f" {extra_where}"
@@ -148,6 +148,7 @@ class PostgreSQLDialect(SQLDialect):
embedding_param: str,
bank_id_param: str,
fetch_limit: int,
min_similarity: float,
tags_clause: str = "",
groups_clause: str = "",
extra_where: str = "",
@@ -161,7 +162,7 @@ class PostgreSQLDialect(SQLDialect):
f" WHERE bank_id = {bank_id_param}"
f" AND fact_type = '{fact_type}'"
f" AND embedding IS NOT NULL"
f" AND (1 - (embedding <=> {embedding_param}::vector)) >= 0.3"
f" AND (1 - (embedding <=> {embedding_param}::vector)) >= {min_similarity}"
f" {tags_clause}"
f" {groups_clause}"
f" {extra_where}"
@@ -183,25 +184,32 @@ class PostgreSQLDialect(SQLDialect):
arm_index: int = 0,
text_search_extension: str = "native",
bm25_language: str = "english",
bm25_min_score: float = 0.0,
extra_where: str = "",
) -> str:
if text_search_extension == "vchord":
# <&> returns a distance (lower = more relevant), negate for score
# <&> returns the NEGATIVE BM25 score (lower = more relevant), negate
# for a positive score where higher = more relevant.
bm25_score_expr = f"-(search_vector <&> to_bm25query('idx_memory_units_text_search', tokenize({text_param}, 'llmlingua2')))"
bm25_order_by = f"{bm25_score_expr} DESC"
bm25_where_filter = ""
# Unlike native tsvector (which has a boolean `@@` match gate), the
# VectorChord operator ranks *every* document, so a bare ORDER BY ...
# LIMIT pads the result with zero-score, non-matching rows. Gate on the
# score so only genuine term matches survive into fusion/reranking.
bm25_where_filter = f"AND -(search_vector <&> to_bm25query('idx_memory_units_text_search', tokenize({text_param}, 'llmlingua2'))) > {bm25_min_score:g}"
elif text_search_extension == "pg_textsearch":
bm25_score_expr = f"-({text_param} <@> to_bm25query({text_param}, 'idx_memory_units_text_search'))"
bm25_order_by = f"text <@> to_bm25query({text_param}, 'idx_memory_units_text_search') ASC"
bm25_where_filter = ""
elif text_search_extension == "pgroonga":
# &@~ accepts pgroonga's query syntax (raw query text). pgroonga_score
# returns a non-negative relevance score (higher = better).
# &@~ accepts pgroonga's query syntax. Escape the bind parameter so
# literal memory text containing operators like ">" or "(" is not
# parsed as a malformed query expression.
bm25_score_expr = "pgroonga_score(tableoid, ctid)"
bm25_order_by = f"{bm25_score_expr} DESC"
bm25_where_filter = (
f"AND (COALESCE(text, '') || ' ' || COALESCE(context, '') || ' ' || COALESCE(text_signals, '')) "
f"&@~ {text_param}"
f"&@~ pgroonga_query_escape({text_param})"
)
elif text_search_extension == "pg_search":
# ParadeDB pg_search: BM25 index over (id, text, context, text_signals)
@@ -0,0 +1,43 @@
"""Document transfer: export/import documents between banks without re-running the LLM.
An export is a ZIP of already-extracted facts (text, entities by canonical name,
causal relations, chunks) never embeddings or DB ids. An import replays the
deterministic half of the retain pipeline against the target bank: it re-embeds
locally with the target bank's embedding model, re-resolves entities, and
recreates temporal/semantic/causal links relative to the target bank's existing
memories. No LLM fact-extraction is involved.
Consolidated observations (``fact_type='observation'``) are intentionally
excluded from export they are derived by consolidation and are regenerated in
the target bank.
"""
from .export import export_bank, export_documents
from .importer import BankImportResult, ImportResult, import_bank, import_documents
from .schema import (
SCHEMA_VERSION,
TransferCausalRelation,
TransferChunk,
TransferDocument,
TransferFact,
TransferManifest,
TransferObservation,
TransferObservationSource,
)
__all__ = [
"SCHEMA_VERSION",
"BankImportResult",
"ImportResult",
"TransferCausalRelation",
"TransferChunk",
"TransferDocument",
"TransferFact",
"TransferManifest",
"TransferObservation",
"TransferObservationSource",
"export_bank",
"export_documents",
"import_bank",
"import_documents",
]
@@ -0,0 +1,555 @@
"""Export documents (with extracted facts, entities, causal links, chunks) to a ZIP archive.
Reads directly from the database via the backend connection. Embeddings and
database ids are deliberately omitted they are regenerated/re-resolved on
import. Consolidated observations are excluded unless ``include_observations``
is set, in which case they are written to ``observations.json``.
"""
from __future__ import annotations
import base64
import io
import json
import logging
import zipfile
from dataclasses import dataclass, field
from datetime import UTC, date, datetime
from decimal import Decimal
from typing import Any
from uuid import UUID
from ..db_utils import acquire_with_retry
from ..schema import fq_table
from .schema import (
SCHEMA_VERSION,
TransferCausalRelation,
TransferChunk,
TransferDocument,
TransferFact,
TransferManifest,
TransferObservation,
TransferObservationSource,
)
logger = logging.getLogger(__name__)
# Whole-bank export classification. Every bank-scoped table (admin.cli.BACKUP_TABLES)
# must fall into exactly one bucket below; tests/test_document_transfer.py's
# test_export_bank_covers_schema enforces this so a table added by a future
# migration can't be silently dropped from a migration archive.
# NOT written to the archive — rebuilt on import by replaying the document/fact/
# observation payload through the import pipeline:
# * documents / chunks / memory_units carry their *text* in the logical document
# payload (TransferDocument) and are re-embedded with the target model;
# * entities / unit_entities / memory_links / entity_cooccurrences are derived
# data — the pipeline re-resolves entities and rebuilds links/cooccurrence
# stats against the target bank, so they are never exported.
# Listed here only so the coverage guard can assert every table is classified.
_REPLAYED_TABLES = frozenset(
{
"documents",
"chunks",
"memory_units",
"entities",
"unit_entities",
"memory_links",
"entity_cooccurrences",
# observation_history FKs to a memory_units observation, but observations
# are derived: they're regenerated with FRESH ids when consolidation is
# replayed on import (see _EXPORTED_FACT_TYPES — observations are excluded).
# There is no stable observation id to re-attach history to, so it is not
# carried; the target rebuilds observation history as it re-consolidates.
"observation_history",
}
)
# Carried verbatim as JSON rows (bank config + synthesized state). Embedding-bearing
# rows have their vector stripped (see _DERIVED_COLUMNS) and are re-embedded on import.
_BANK_ROW_TABLES = ("banks", "mental_models", "directives", "webhooks")
# Bank-scoped child-history carried verbatim. Unlike observations, mental models
# keep their (id, bank_id) across export/import, so their refresh history can be
# re-attached. The surrogate ``id`` is dropped on dump so the target reassigns it
# (see _dump_history_rows); restored after its parent table (mental_models).
_CARRIED_HISTORY_TABLES = ("mental_model_history",)
# Operational history — only carried with include_history=True.
_HISTORY_TABLES = ("audit_log", "llm_requests")
# Intentionally never exported.
_SKIP_TABLES = frozenset(
{
"async_operations", # in-flight ops; drain on the source before migrating
"graph_maintenance_queue", # transient work queue; regenerated on import
"file_storage", # raw uploads; documents.original_text is already carried
}
)
# Derived columns dropped from carried rows so the target regenerates them with
# its own embedding model / text-search backend.
_DERIVED_COLUMNS = ("embedding", "search_vector")
@dataclass
class _UnitLocation:
"""Where a memory unit's fact lives in the assembled export (document + ordinal)."""
document_id: str
ordinal: int
@dataclass
class _LoadedFacts:
"""Facts grouped by document plus an index from unit id to its location.
``facts_by_doc`` and ``unit_index`` share the same fixed ordering so that
causal ``target_fact_index`` ordinals stay consistent across both.
"""
facts_by_doc: dict[str, list[TransferFact]] = field(default_factory=dict)
unit_index: dict[Any, _UnitLocation] = field(default_factory=dict)
@dataclass
class _LoadedExport:
"""Assembled documents plus the unit-id → location index.
``unit_index`` is retained so observation source unit ids can be resolved to
(document_id, fact_index) references when observations are exported.
"""
documents: list[TransferDocument] = field(default_factory=list)
unit_index: dict[Any, _UnitLocation] = field(default_factory=dict)
# Causal link types that retain persists between facts. Only these travel in the
# archive; temporal/semantic/entity links are regenerated against the target bank.
_CAUSAL_LINK_TYPES = ("caused_by", "causes", "enables", "prevents")
# Facts of these types are exported; observations are derived and excluded.
_EXPORTED_FACT_TYPES = ("world", "experience")
def _as_jsonb(value: Any) -> Any:
"""Coerce an asyncpg JSONB column (str or already-decoded) to a Python object."""
if value is None:
return None
if isinstance(value, str):
return json.loads(value)
return value
def _chunk_index_from_chunk_id(chunk_id: str | None) -> int | None:
"""Recover the chunk ordinal from a ``{bank_id}_{document_id}_{index}`` chunk_id.
The index is always the final underscore-delimited segment, so rsplit is
correct even when bank/document ids themselves contain underscores.
"""
if not chunk_id:
return None
try:
return int(chunk_id.rsplit("_", 1)[1])
except (IndexError, ValueError):
return None
async def export_documents(
backend: Any,
bank_id: str,
document_ids: list[str] | None = None,
*,
include_observations: bool = False,
) -> bytes:
"""Export documents from ``bank_id`` into an in-memory ZIP archive.
Args:
backend: Database backend (provides ``acquire()``).
bank_id: Source bank.
document_ids: Specific document ids to export. ``None`` exports every
document in the bank.
include_observations: Also export consolidated observations (written to
``observations.json``). Only valid for a whole-bank export.
Returns:
The ZIP archive as bytes.
Raises:
ValueError: if ``include_observations`` is combined with ``document_ids``.
"""
# Observations are bank-level and can be derived from facts spanning several
# documents, so they're only coherent when the whole bank is exported. For a
# document subset we'd have to silently drop every cross-document observation
# — reject the combination instead so the caller isn't surprised.
if include_observations and document_ids is not None:
raise ValueError("include_observations is only supported when exporting the whole bank (omit document_id)")
async with acquire_with_retry(backend) as conn:
loaded = await _load_documents(conn, bank_id, document_ids)
documents = loaded.documents
observations = await _load_observations(conn, bank_id, loaded.unit_index) if include_observations else []
archive = io.BytesIO()
fact_total = 0
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
for index, document in enumerate(documents):
fact_total += len(document.facts)
zf.writestr(
f"documents/{index:06d}.json",
document.model_dump_json(indent=2, exclude_none=False),
)
if observations:
payload = "[\n" + ",\n".join(o.model_dump_json(indent=2) for o in observations) + "\n]\n"
zf.writestr("observations.json", payload)
manifest = TransferManifest(
schema_version=SCHEMA_VERSION,
source_bank_id=bank_id,
exported_at=datetime.now(UTC),
document_count=len(documents),
fact_count=fact_total,
observation_count=len(observations),
)
zf.writestr("manifest.json", manifest.model_dump_json(indent=2))
logger.info(
"[transfer] Exported %d document(s), %d fact(s), %d observation(s) from bank %s",
len(documents),
fact_total,
len(observations),
bank_id,
)
return archive.getvalue()
def _row_json_default(obj: Any) -> Any:
"""JSON serializer for the value types asyncpg returns from bank rows."""
if isinstance(obj, datetime):
return obj.isoformat()
if isinstance(obj, date):
return obj.isoformat()
if isinstance(obj, UUID):
return str(obj)
if isinstance(obj, Decimal):
# str preserves precision; import casts back to numeric.
return str(obj)
if isinstance(obj, (bytes, bytearray, memoryview)):
return base64.b64encode(bytes(obj)).decode("ascii")
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
async def _dump_bank_rows(conn: Any, table: str, bank_id: str) -> list[dict]:
"""Dump all rows of a bank-scoped table as JSON-ready dicts (derived columns stripped).
Embedding/search-vector columns are omitted so the target instance
regenerates them with its own model/backend on import.
"""
rows = await conn.fetch(f"SELECT * FROM {fq_table(table)} WHERE bank_id = $1", bank_id)
return [{k: v for k, v in dict(row).items() if k not in _DERIVED_COLUMNS} for row in rows]
async def _dump_history_rows(conn: Any, table: str, bank_id: str) -> list[dict]:
"""Dump a bank-scoped child-history table for carrying across instances.
Drops the surrogate ``id`` so the target reassigns it from its own IDENTITY
sequence (carrying explicit ids would leave the sequence un-advanced and
collide with later writes). Ordered oldest-first so the reassigned ids keep
the same chronological tie-break order the read path relies on.
"""
rows = await conn.fetch(
f"SELECT * FROM {fq_table(table)} WHERE bank_id = $1 ORDER BY changed_at, id",
bank_id,
)
return [{k: v for k, v in dict(row).items() if k not in _DERIVED_COLUMNS and k != "id"} for row in rows]
async def export_bank(conn: Any, bank_id: str, *, include_history: bool = False) -> bytes:
"""Export an entire bank into a portable ZIP archive (no embeddings).
Produces a superset of the documents archive: the logical
document/fact/observation export (replayed and re-embedded on import) plus
the bank's config, mental models, directives and webhooks as JSON rows. With
``include_history`` the operational tails (audit_log, llm_requests) are also
carried. Intended for migrating a bank to a new instance configured with a
different embedding model / vector / text-search backend every vector is
regenerated on the target, so nothing here is encoder-specific.
``conn`` is a live connection scoped to the bank's schema (the admin CLI sets
``_current_schema`` and passes its raw connection; the engine acquires one
after tenant auth).
"""
loaded = await _load_documents(conn, bank_id, None)
documents = loaded.documents
# Whole-bank export always carries observations (they're bank-level state).
observations = await _load_observations(conn, bank_id, loaded.unit_index)
bank_rows = {table: await _dump_bank_rows(conn, table, bank_id) for table in _BANK_ROW_TABLES}
for table in _CARRIED_HISTORY_TABLES:
bank_rows[table] = await _dump_history_rows(conn, table, bank_id)
history_rows: dict[str, list[dict]] = {}
if include_history:
history_rows = {table: await _dump_bank_rows(conn, table, bank_id) for table in _HISTORY_TABLES}
archive = io.BytesIO()
fact_total = 0
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
for index, document in enumerate(documents):
fact_total += len(document.facts)
zf.writestr(f"documents/{index:06d}.json", document.model_dump_json(indent=2, exclude_none=False))
if observations:
payload = "[\n" + ",\n".join(o.model_dump_json(indent=2) for o in observations) + "\n]\n"
zf.writestr("observations.json", payload)
for table, rows in bank_rows.items():
zf.writestr(f"{table}.json", json.dumps(rows, indent=2, default=_row_json_default))
for table, rows in history_rows.items():
zf.writestr(f"history/{table}.json", json.dumps(rows, indent=2, default=_row_json_default))
manifest = TransferManifest(
schema_version=SCHEMA_VERSION,
source_bank_id=bank_id,
exported_at=datetime.now(UTC),
document_count=len(documents),
fact_count=fact_total,
observation_count=len(observations),
archive_type="bank",
mental_model_count=len(bank_rows.get("mental_models", [])),
directive_count=len(bank_rows.get("directives", [])),
webhook_count=len(bank_rows.get("webhooks", [])),
includes_history=include_history,
)
zf.writestr("manifest.json", manifest.model_dump_json(indent=2))
logger.info(
"[transfer] Exported bank %s: %d document(s), %d fact(s), %d observation(s), "
"%d mental model(s), %d directive(s), %d webhook(s)%s",
bank_id,
len(documents),
fact_total,
len(observations),
len(bank_rows.get("mental_models", [])),
len(bank_rows.get("directives", [])),
len(bank_rows.get("webhooks", [])),
" (with history)" if include_history else "",
)
return archive.getvalue()
async def _load_documents(
conn: Any,
bank_id: str,
document_ids: list[str] | None,
) -> _LoadedExport:
"""Load and assemble TransferDocument payloads for the requested documents."""
doc_filter = "AND id = ANY($2)" if document_ids else ""
params: list[Any] = [bank_id]
if document_ids:
params.append(document_ids)
doc_rows = await conn.fetch(
f"""
SELECT id, original_text, retain_params, tags, created_at
FROM {fq_table("documents")}
WHERE bank_id = $1 {doc_filter}
ORDER BY created_at, id
""",
*params,
)
if not doc_rows:
return _LoadedExport()
selected_ids = [row["id"] for row in doc_rows]
chunks_by_doc = await _load_chunks(conn, bank_id, selected_ids)
loaded = await _load_facts(conn, bank_id, selected_ids)
await _attach_entities(conn, loaded)
await _attach_causal_relations(conn, loaded)
documents: list[TransferDocument] = []
for row in doc_rows:
doc_id = row["id"]
documents.append(
TransferDocument(
id=doc_id,
original_text=row["original_text"],
retain_params=_as_jsonb(row["retain_params"]),
tags=list(row["tags"] or []),
created_at=row["created_at"],
chunks=chunks_by_doc.get(doc_id, []),
facts=loaded.facts_by_doc.get(doc_id, []),
)
)
return _LoadedExport(documents=documents, unit_index=loaded.unit_index)
async def _load_observations(
conn: Any,
bank_id: str,
unit_index: dict[Any, _UnitLocation],
) -> list[TransferObservation]:
"""Load observations whose source facts are all present in the exported set.
Each source unit id is rewritten to its (document_id, fact_index) reference
via ``unit_index``. Only called for a whole-bank export, so every live source
fact is present; an observation is skipped only if a source no longer exists
(stale reference) that keeps every exported observation resolvable on import.
"""
rows = await conn.fetch(
f"""
SELECT id, text, tags, event_date, occurred_start, occurred_end,
mentioned_at, observation_scopes, proof_count, source_memory_ids
FROM {fq_table("memory_units")}
WHERE bank_id = $1 AND fact_type = 'observation'
ORDER BY created_at, id
""",
bank_id,
)
observations: list[TransferObservation] = []
skipped = 0
for row in rows:
source_ids = list(row["source_memory_ids"] or [])
locations = [unit_index.get(sid) for sid in source_ids]
if not source_ids or any(loc is None for loc in locations):
# An observation with sources outside the exported documents would be
# incoherent on import — skip it rather than emit dangling refs.
skipped += 1
continue
observations.append(
TransferObservation(
text=row["text"],
tags=list(row["tags"] or []),
event_date=row["event_date"],
occurred_start=row["occurred_start"],
occurred_end=row["occurred_end"],
mentioned_at=row["mentioned_at"],
observation_scopes=_as_jsonb(row["observation_scopes"]),
proof_count=row["proof_count"] or len(source_ids),
sources=[
TransferObservationSource(document_id=loc.document_id, fact_index=loc.ordinal)
for loc in locations
if loc is not None
],
)
)
if skipped:
logger.info("[transfer] Skipped %d observation(s) with sources outside the exported documents", skipped)
return observations
async def _load_chunks(conn: Any, bank_id: str, doc_ids: list[str]) -> dict[str, list[TransferChunk]]:
rows = await conn.fetch(
f"""
SELECT document_id, chunk_index, chunk_text
FROM {fq_table("chunks")}
WHERE bank_id = $1 AND document_id = ANY($2)
ORDER BY document_id, chunk_index
""",
bank_id,
doc_ids,
)
chunks_by_doc: dict[str, list[TransferChunk]] = {}
for row in rows:
chunks_by_doc.setdefault(row["document_id"], []).append(
TransferChunk(chunk_index=row["chunk_index"], chunk_text=row["chunk_text"])
)
return chunks_by_doc
async def _load_facts(conn: Any, bank_id: str, doc_ids: list[str]) -> _LoadedFacts:
"""Load non-observation facts grouped by document, with a unit-id location index.
The ordering is fixed (created_at, id) so that
``causal_relations.target_fact_index`` ordinals stay consistent.
"""
rows = await conn.fetch(
f"""
SELECT id, document_id, text, fact_type, context, event_date,
occurred_start, occurred_end, mentioned_at, metadata,
chunk_id, tags, observation_scopes
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND document_id = ANY($2)
AND fact_type = ANY($3)
ORDER BY document_id, created_at, id
""",
bank_id,
doc_ids,
list(_EXPORTED_FACT_TYPES),
)
loaded = _LoadedFacts()
for row in rows:
doc_id = row["document_id"]
bucket = loaded.facts_by_doc.setdefault(doc_id, [])
ordinal = len(bucket)
fact = TransferFact(
text=row["text"],
fact_type=row["fact_type"],
context=row["context"],
event_date=row["event_date"],
occurred_start=row["occurred_start"],
occurred_end=row["occurred_end"],
mentioned_at=row["mentioned_at"],
metadata=_as_jsonb(row["metadata"]) or {},
tags=list(row["tags"] or []),
observation_scopes=_as_jsonb(row["observation_scopes"]),
chunk_index=_chunk_index_from_chunk_id(row["chunk_id"]),
)
bucket.append(fact)
loaded.unit_index[row["id"]] = _UnitLocation(document_id=doc_id, ordinal=ordinal)
return loaded
async def _attach_entities(conn: Any, loaded: _LoadedFacts) -> None:
"""Populate each fact's ``entities`` list with its entities' canonical names."""
if not loaded.unit_index:
return
rows = await conn.fetch(
f"""
SELECT ue.unit_id, 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)
ORDER BY e.canonical_name
""",
list(loaded.unit_index.keys()),
)
for row in rows:
location = loaded.unit_index.get(row["unit_id"])
if location is None:
continue
loaded.facts_by_doc[location.document_id][location.ordinal].entities.append(row["canonical_name"])
async def _attach_causal_relations(conn: Any, loaded: _LoadedFacts) -> None:
"""Reconstruct causal edges as fact ordinals within each document.
A memory_link (from_unit -> to_unit, link_type) means ``from_unit`` carries
the relation pointing at ``to_unit``, so the edge is attached to the source
fact with the target's ordinal. Edges spanning two documents are skipped
(causal links are created within a single retain batch in practice).
"""
if not loaded.unit_index:
return
rows = await conn.fetch(
f"""
SELECT from_unit_id, to_unit_id, link_type
FROM {fq_table("memory_links")}
WHERE link_type = ANY($1)
AND from_unit_id = ANY($2)
AND to_unit_id = ANY($2)
""",
list(_CAUSAL_LINK_TYPES),
list(loaded.unit_index.keys()),
)
for row in rows:
source = loaded.unit_index.get(row["from_unit_id"])
target = loaded.unit_index.get(row["to_unit_id"])
if source is None or target is None:
continue
if source.document_id != target.document_id:
continue
loaded.facts_by_doc[source.document_id][source.ordinal].causal_relations.append(
TransferCausalRelation(
relation_type=row["link_type"],
target_fact_index=target.ordinal,
)
)
@@ -0,0 +1,716 @@
"""Import documents from a transfer archive by replaying the deterministic retain pipeline.
For each document the importer rebuilds the extracted facts, re-embeds them with
the *target* bank's embedding model, then runs entity resolution (Phase 1) and
the fact/link insert (Phase 2) exactly the steps retain runs after LLM
extraction. No LLM is called. Temporal/semantic/causal links and entity merges
are therefore computed relative to the target bank's existing memories.
"""
from __future__ import annotations
import io
import json
import logging
import uuid
import zipfile
from dataclasses import dataclass, field
from datetime import UTC, date, datetime
from typing import Any, Literal
from ..db_utils import acquire_with_retry
from ..retain import bank_utils, chunk_storage, embedding_processing, fact_storage, orchestrator
from ..retain.types import (
CausalRelation,
ChunkMetadata,
ExtractedFact,
ProcessedFact,
RetainContent,
)
from ..schema import fq_table
from .schema import (
SCHEMA_VERSION,
TransferDocument,
TransferFact,
TransferManifest,
TransferObservation,
)
logger = logging.getLogger(__name__)
OnConflict = Literal["skip", "replace", "new-id"]
_VALID_CONFLICT_MODES: tuple[OnConflict, ...] = ("skip", "replace", "new-id")
@dataclass
class ImportedDocument:
"""A single document successfully imported, with the units it produced.
Carried back so the engine can fire the post-retain extension hook
(usage tracking / metrics / notifications) once per imported document,
mirroring how retain reports each completed document.
"""
document_id: str
unit_ids: list[str]
content: str
tags: list[str]
@dataclass
class ImportResult:
"""Outcome of importing a transfer archive into a bank."""
documents_imported: int = 0
documents_skipped: int = 0
facts_imported: int = 0
observations_imported: int = 0
# Observations dropped because some source fact was not imported in this run.
observations_skipped: int = 0
skipped_document_ids: list[str] = field(default_factory=list)
# Original id -> freshly generated id, for documents imported under "new-id".
remapped_document_ids: dict[str, str] = field(default_factory=dict)
# Per-document outcomes, for the engine's post-retain hook. Not serialized
# into operation result_metadata (the worker handler writes counts only).
imported_documents: list[ImportedDocument] = field(default_factory=list)
@dataclass
class _ObservationOutcome:
"""Counts from the observation import pass."""
imported: int = 0
skipped: int = 0
@dataclass
class ParsedArchive:
"""A transfer archive after parsing/validation."""
manifest: TransferManifest
documents: list[TransferDocument]
observations: list[TransferObservation] = field(default_factory=list)
def parse_archive(archive_bytes: bytes) -> ParsedArchive:
"""Parse and validate a transfer ZIP archive produced by ``export_documents``."""
with zipfile.ZipFile(io.BytesIO(archive_bytes), "r") as zf:
names = set(zf.namelist())
if "manifest.json" not in names:
raise ValueError("Invalid transfer archive: manifest.json is missing")
manifest = TransferManifest.model_validate_json(zf.read("manifest.json"))
if manifest.schema_version != SCHEMA_VERSION:
raise ValueError(
f"Unsupported transfer archive schema version {manifest.schema_version} "
f"(this build supports {SCHEMA_VERSION})"
)
doc_names = sorted(n for n in names if n.startswith("documents/") and n.endswith(".json"))
documents = [TransferDocument.model_validate_json(zf.read(name)) for name in doc_names]
observations: list[TransferObservation] = []
if "observations.json" in names:
observations = [TransferObservation.model_validate(o) for o in json.loads(zf.read("observations.json"))]
return ParsedArchive(manifest=manifest, documents=documents, observations=observations)
async def import_documents(
*,
backend: Any,
embeddings_model: Any,
entity_resolver: Any,
config: Any,
format_date_fn: Any,
bank_id: str,
archive_bytes: bytes,
on_conflict: OnConflict = "skip",
ops: Any = None,
outbox_callback_factory: Any = None,
) -> ImportResult:
"""Import every document in ``archive_bytes`` into ``bank_id``.
Args:
backend: Database backend (provides ``acquire()`` and ``ops``).
embeddings_model: Target bank's embedding model (used to re-embed facts).
entity_resolver: Shared entity resolver for the target bank.
config: Resolved bank config for the target bank.
format_date_fn: Date formatter used when augmenting fact text for embedding
(must match retain so embeddings are consistent).
bank_id: Target bank.
archive_bytes: A ZIP archive produced by ``export_documents``.
on_conflict: How to handle a document id that already exists in the target
bank ``skip`` (default), ``replace`` (delete old data and re-import),
or ``new-id`` (import under a freshly generated id).
ops: Backend ``DataAccessOps``. Defaults to ``backend.ops``.
Returns:
An :class:`ImportResult` with per-document counts.
"""
if on_conflict not in _VALID_CONFLICT_MODES:
raise ValueError(f"Invalid on_conflict '{on_conflict}'; expected one of {_VALID_CONFLICT_MODES}")
if ops is None:
ops = backend.ops
parsed = parse_archive(archive_bytes)
result = ImportResult()
# (original document_id, fact ordinal) -> freshly inserted unit id. Used to
# resolve observation source references after all facts exist.
ref_map: dict[tuple[str, int], str] = {}
for document in parsed.documents:
target_id = await _resolve_target_id(backend, bank_id, document.id, on_conflict)
if target_id is None:
result.documents_skipped += 1
result.skipped_document_ids.append(document.id)
continue
if target_id != document.id:
result.remapped_document_ids[document.id] = target_id
unit_ids = await _import_one_document(
backend=backend,
embeddings_model=embeddings_model,
entity_resolver=entity_resolver,
config=config,
format_date_fn=format_date_fn,
bank_id=bank_id,
document=document,
target_id=target_id,
ops=ops,
outbox_callback_factory=outbox_callback_factory,
)
result.documents_imported += 1
result.facts_imported += len(unit_ids)
result.imported_documents.append(
ImportedDocument(
document_id=target_id,
unit_ids=unit_ids,
content=document.original_text or "",
tags=list(document.tags),
)
)
for ordinal, unit_id in enumerate(unit_ids):
ref_map[(document.id, ordinal)] = unit_id
if parsed.observations:
outcome = await _import_observations(
backend=backend,
embeddings_model=embeddings_model,
bank_id=bank_id,
observations=parsed.observations,
ref_map=ref_map,
ops=ops,
)
result.observations_imported = outcome.imported
result.observations_skipped = outcome.skipped
logger.info(
"[transfer] Imported %d document(s), %d fact(s), %d observation(s) into bank %s "
"(%d docs skipped, %d observations skipped)",
result.documents_imported,
result.facts_imported,
result.observations_imported,
bank_id,
result.documents_skipped,
result.observations_skipped,
)
return result
# Bank-level config/state tables restored verbatim from a whole-bank archive.
# Order matters for foreign keys: banks (parent) is restored before any child.
_BANK_CHILD_TABLES = ("mental_models", "directives", "webhooks")
# Child-history carried verbatim; restored after its parent (mental_models) so the
# foreign key resolves. Surrogate ids were dropped on export (the target reassigns
# them), so these restore via fresh IDENTITY values.
_CARRIED_HISTORY_TABLES = ("mental_model_history",)
_HISTORY_TABLES = ("audit_log", "llm_requests")
@dataclass
class BankImportResult:
"""Outcome of importing a whole-bank archive."""
bank_id: str
documents_imported: int = 0
facts_imported: int = 0
observations_imported: int = 0
mental_models_imported: int = 0
mental_model_history_imported: int = 0
directives_imported: int = 0
webhooks_imported: int = 0
history_rows_imported: int = 0
@dataclass
class ParsedBankArchive:
"""The bank-level sections of a whole-bank archive (documents read separately)."""
manifest: TransferManifest
# table name -> list of verbatim row dicts (banks, mental_models, directives, webhooks)
bank_rows: dict[str, list[dict]] = field(default_factory=dict)
# table name -> rows (audit_log, llm_requests), present only with --include-history
history_rows: dict[str, list[dict]] = field(default_factory=dict)
def parse_bank_archive(archive_bytes: bytes) -> ParsedBankArchive:
"""Parse the bank-level sections of a whole-bank archive (``archive_type='bank'``)."""
with zipfile.ZipFile(io.BytesIO(archive_bytes), "r") as zf:
names = set(zf.namelist())
if "manifest.json" not in names:
raise ValueError("Invalid transfer archive: manifest.json is missing")
manifest = TransferManifest.model_validate_json(zf.read("manifest.json"))
if manifest.archive_type != "bank":
raise ValueError(
f"Not a whole-bank archive (archive_type={manifest.archive_type!r}); use import_documents instead"
)
bank_rows: dict[str, list[dict]] = {}
for table in ("banks", *_BANK_CHILD_TABLES, *_CARRIED_HISTORY_TABLES):
fname = f"{table}.json"
bank_rows[table] = json.loads(zf.read(fname)) if fname in names else []
history_rows: dict[str, list[dict]] = {}
for table in _HISTORY_TABLES:
fname = f"history/{table}.json"
if fname in names:
history_rows[table] = json.loads(zf.read(fname))
return ParsedBankArchive(manifest=manifest, bank_rows=bank_rows, history_rows=history_rows)
async def _restore_rows(conn: Any, table: str, rows: list[dict]) -> int:
"""Insert verbatim rows into a bank-scoped table, coercing JSON-encoded values
back to the column's type (timestamps, uuids, jsonb). ``ON CONFLICT DO NOTHING``
keeps an import idempotent and safe to re-run against a partially-filled target."""
if not rows:
return 0
from ..memory_engine import get_current_schema
schema = get_current_schema()
col_types = {
r["column_name"]: r["data_type"]
for r in await conn.fetch(
"SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2",
schema,
table,
)
}
inserted = 0
for row in rows:
cols = [c for c in row if c in col_types]
placeholders: list[str] = []
values: list[Any] = []
for position, col in enumerate(cols, start=1):
data_type = col_types[col]
value = row[col]
if data_type in ("jsonb", "json"):
# asyncpg has no JSON codec on these raw connections; pass JSON
# text and cast. Values may already be str (no codec on export) or
# a Python object (codec on export) — normalize to text either way.
values.append(value if isinstance(value, str) or value is None else json.dumps(value))
placeholders.append(f"${position}::jsonb")
continue
if value is not None and isinstance(value, str):
if data_type in ("timestamp with time zone", "timestamp without time zone"):
value = datetime.fromisoformat(value)
elif data_type == "date":
value = date.fromisoformat(value)
elif data_type == "uuid":
value = uuid.UUID(value)
placeholders.append(f"${position}")
values.append(value)
col_list = ", ".join(f'"{c}"' for c in cols)
await conn.execute(
f"INSERT INTO {fq_table(table)} ({col_list}) VALUES ({', '.join(placeholders)}) ON CONFLICT DO NOTHING",
*values,
)
inserted += 1
return inserted
async def import_bank(
*,
backend: Any,
embeddings_model: Any,
entity_resolver: Any,
config: Any,
format_date_fn: Any,
archive_bytes: bytes,
target_bank_id: str | None = None,
include_history: bool = False,
ops: Any = None,
) -> BankImportResult:
"""Restore a whole bank from a ``export_bank`` archive into the target instance.
Re-embeds facts with the *target* instance's embedding model and rebuilds links,
entities and search/vector indexes the path for migrating a bank to an instance
configured with a different embedding model / vector / text-search backend.
The **target bank must not already exist**: import restores a complete bank
(config + facts + mental models + ) and is not a merge. If a bank with the
target id is present, this raises delete it first or pass ``target_bank_id``
for a fresh id. A migration restores *exact* state, so unlike the document
import it fires no retain webhooks and triggers no consolidation/graph
maintenance: observations and mental models are restored as exported.
"""
if ops is None:
ops = backend.ops
parsed = parse_bank_archive(archive_bytes)
source_bank_id = parsed.manifest.source_bank_id
bank_id = target_bank_id or source_bank_id
# Remapping to a different id: rewrite the carried bank_id on every row so FKs
# and PKs line up with the (also-remapped) documents/facts.
if bank_id != source_bank_id:
for rows in (*parsed.bank_rows.values(), *parsed.history_rows.values()):
for row in rows:
if "bank_id" in row:
row["bank_id"] = bank_id
async with acquire_with_retry(backend) as conn:
# Refuse to import into an existing bank — this restores a whole bank, it
# does not merge. Merging would silently mix the archive's config/mental
# models/webhooks with whatever is already there (and global-unique ids
# like webhooks/directives would collide).
if await conn.fetchval(f"SELECT 1 FROM {fq_table('banks')} WHERE bank_id = $1", bank_id):
raise ValueError(
f"Target bank '{bank_id}' already exists; import-bank restores into a fresh bank "
f"(it is not a merge). Delete the bank first, or pass a different target bank id."
)
# Bank row first — children (documents, mental_models, …) FK to it.
await _restore_rows(conn, "banks", parsed.bank_rows.get("banks", []))
# Ensure the bank's per-bank vector indexes exist (no-op for global-index
# extensions); idempotent and keeps the restored banks row (ON CONFLICT DO NOTHING).
await bank_utils.get_or_create_bank_profile(backend, bank_id)
doc_result = await import_documents(
backend=backend,
embeddings_model=embeddings_model,
entity_resolver=entity_resolver,
config=config,
format_date_fn=format_date_fn,
bank_id=bank_id,
archive_bytes=archive_bytes,
ops=ops,
outbox_callback_factory=None,
)
result = BankImportResult(
bank_id=bank_id,
documents_imported=doc_result.documents_imported,
facts_imported=doc_result.facts_imported,
observations_imported=doc_result.observations_imported,
)
async with acquire_with_retry(backend) as conn:
result.mental_models_imported = await _restore_rows(
conn, "mental_models", parsed.bank_rows.get("mental_models", [])
)
# Restored after mental_models so the (mental_model_id, bank_id) FK resolves.
result.mental_model_history_imported = await _restore_rows(
conn, "mental_model_history", parsed.bank_rows.get("mental_model_history", [])
)
result.directives_imported = await _restore_rows(conn, "directives", parsed.bank_rows.get("directives", []))
result.webhooks_imported = await _restore_rows(conn, "webhooks", parsed.bank_rows.get("webhooks", []))
if include_history:
for table in _HISTORY_TABLES:
result.history_rows_imported += await _restore_rows(conn, table, parsed.history_rows.get(table, []))
logger.info(
"[transfer] Imported bank %s: %d doc(s), %d fact(s), %d observation(s), "
"%d mental model(s), %d mm-history row(s), %d directive(s), %d webhook(s), %d history row(s)",
bank_id,
result.documents_imported,
result.facts_imported,
result.observations_imported,
result.mental_models_imported,
result.mental_model_history_imported,
result.directives_imported,
result.webhooks_imported,
result.history_rows_imported,
)
return result
async def _resolve_target_id(backend: Any, bank_id: str, document_id: str, on_conflict: OnConflict) -> str | None:
"""Decide the document id to write under, or ``None`` to skip.
Returns the original id when there is no conflict, a fresh id under
``new-id``, the original id under ``replace`` (the insert path cascades the
old data away), or ``None`` under ``skip`` when the document already exists.
"""
async with acquire_with_retry(backend) as conn:
exists = await conn.fetchval(
f"SELECT 1 FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2",
document_id,
bank_id,
)
if not exists:
return document_id
if on_conflict == "skip":
return None
if on_conflict == "new-id":
return str(uuid.uuid4())
return document_id # replace
async def _import_one_document(
*,
backend: Any,
embeddings_model: Any,
entity_resolver: Any,
config: Any,
format_date_fn: Any,
bank_id: str,
document: TransferDocument,
target_id: str,
ops: Any,
outbox_callback_factory: Any = None,
) -> list[str]:
"""Re-embed and insert a single document; returns the new unit ids in fact order."""
log_buffer: list[str] = []
# Fire the same retain.completed webhook retain emits, transactionally inside
# this document's insert. Factory returns None when no webhook manager exists.
outbox_callback = (
outbox_callback_factory([{"document_id": target_id, "tags": list(document.tags)}])
if outbox_callback_factory
else None
)
extracted_facts = [_to_extracted_fact(fact) for fact in document.facts]
processed_facts: list[ProcessedFact] = []
if extracted_facts:
augmented = embedding_processing.augment_texts_with_dates(extracted_facts, format_date_fn)
embeddings = await embedding_processing.generate_embeddings_batch(embeddings_model, augmented)
processed_facts = [ProcessedFact.from_extracted_fact(ef, emb) for ef, emb in zip(extracted_facts, embeddings)]
contents = [RetainContent(content=document.original_text or "")]
chunk_meta = [
ChunkMetadata(chunk_text=chunk.chunk_text, fact_count=0, content_index=0, chunk_index=chunk.chunk_index)
for chunk in document.chunks
]
# Phase 1 (entity resolution + semantic ANN) on its own connection, outside
# the write transaction — mirrors the retain pipeline.
entity_resolver.discard_pending_stats()
phase1 = await orchestrator._pre_resolve_phase1(
backend,
entity_resolver,
bank_id,
contents,
processed_facts,
config,
log_buffer,
skip_semantic_ann=False,
)
async with acquire_with_retry(backend) as conn:
async with conn.transaction():
# is_first_batch=True: cascade-delete any existing data for this id
# (the "replace" path) and (re)insert the document row.
await fact_storage.handle_document_tracking(
conn,
bank_id,
target_id,
document.original_text or "",
True,
document.retain_params,
document.tags,
ops=ops,
)
chunk_id_map: dict[int, str] = {}
if chunk_meta:
chunk_id_map = await chunk_storage.store_chunks_batch(conn, bank_id, target_id, chunk_meta, ops=ops)
for extracted, processed in zip(extracted_facts, processed_facts):
processed.document_id = target_id
if chunk_id_map and extracted.chunk_index is not None:
chunk_id = chunk_id_map.get(extracted.chunk_index)
if chunk_id:
processed.chunk_id = chunk_id
result_unit_ids = await orchestrator._insert_facts_and_links(
conn,
entity_resolver,
bank_id,
contents,
extracted_facts,
processed_facts,
config,
log_buffer,
resolved_entity_ids=phase1.entities.resolved_entity_ids,
entity_to_unit=phase1.entities.entity_to_unit,
unit_to_entity_ids=phase1.entities.unit_to_entity_ids,
semantic_ann_links=phase1.semantic_ann_links,
skip_semantic_links=False,
outbox_callback=outbox_callback,
ops=ops,
)
try:
await entity_resolver.flush_pending_stats()
except Exception:
logger.warning("[transfer] Entity stats flush failed for document %s", target_id, exc_info=True)
logger.debug("[transfer] Imported document %s:\n%s", target_id, "\n".join(log_buffer))
# Single content item -> result_unit_ids[0] holds the new unit ids in fact order.
return list(result_unit_ids[0]) if result_unit_ids else []
async def _import_observations(
*,
backend: Any,
embeddings_model: Any,
bank_id: str,
observations: list[TransferObservation],
ref_map: dict[tuple[str, int], str],
ops: Any,
) -> _ObservationOutcome:
"""Insert observations whose source facts were all imported in this run.
Observations carry no embedding, links, or entity rows only the unit row
plus ``source_memory_ids`` (remapped to the freshly inserted source units)
and ``proof_count``. Their source facts are marked ``consolidated_at`` so the
target bank's consolidator won't re-process them. Mirrors what consolidation
writes, but driven from the archive instead of the LLM.
Inserted as-is: imported observations are NOT merged or deduplicated against
observations that already exist in the target bank (unlike consolidation,
which merges related observations). Importing into a bank that already has
observations or importing the same archive twice can therefore produce
overlapping observations over the same facts.
"""
outcome = _ObservationOutcome()
# Resolve each observation's sources to new unit ids; drop any whose sources
# weren't all imported (e.g. a subset/skip import).
resolved: list[tuple[TransferObservation, list[str]]] = []
for obs in observations:
source_ids = [ref_map.get((s.document_id, s.fact_index)) for s in obs.sources]
if not source_ids or any(sid is None for sid in source_ids):
outcome.skipped += 1
continue
resolved.append((obs, [sid for sid in source_ids if sid is not None]))
if not resolved:
return outcome
# Observations embed the raw text (matching consolidation), not the
# date-augmented text used for facts.
embeddings = await embedding_processing.generate_embeddings_batch(
embeddings_model, [obs.text for obs, _ in resolved]
)
processed = [
ProcessedFact(
fact_text=obs.text,
fact_type="observation",
embedding=embedding,
occurred_start=obs.occurred_start,
occurred_end=obs.occurred_end,
mentioned_at=_observation_mentioned_at(obs),
context="",
metadata={},
tags=list(obs.tags),
observation_scopes=obs.observation_scopes,
document_id=None,
chunk_id=None,
)
for (obs, _sources), embedding in zip(resolved, embeddings)
]
async with acquire_with_retry(backend) as conn:
async with conn.transaction():
obs_unit_ids = await fact_storage.insert_facts_batch(conn, bank_id, processed, ops=ops)
all_source_ids: set[uuid.UUID] = set()
for (obs, sources), obs_unit_id in zip(resolved, obs_unit_ids):
source_uuids = [uuid.UUID(s) for s in sources]
all_source_ids.update(source_uuids)
await _link_observation_sources(
conn, ops, bank_id, uuid.UUID(obs_unit_id), source_uuids, obs.proof_count
)
# Mark source facts consolidated so the target consolidator skips them.
if all_source_ids:
await conn.execute(
f"UPDATE {fq_table('memory_units')} SET consolidated_at = now() "
f"WHERE bank_id = $1 AND id = ANY($2)",
bank_id,
list(all_source_ids),
)
outcome.imported = len(resolved)
return outcome
async def _link_observation_sources(
conn: Any,
ops: Any,
bank_id: str,
observation_id: uuid.UUID,
source_ids: list[uuid.UUID],
proof_count: int,
) -> None:
"""Attach source ids + proof_count to a freshly inserted observation row.
PG stores the sources in the ``source_memory_ids`` array column; Oracle uses
the ``observation_sources`` junction table (same split as consolidation).
"""
if ops.uses_observation_sources_table:
await conn.executemany(
f"INSERT INTO {fq_table('observation_sources')} (observation_id, source_id) "
f"VALUES ($1, $2) ON CONFLICT (observation_id, source_id) DO NOTHING",
[(observation_id, sid) for sid in dict.fromkeys(source_ids)],
)
await conn.execute(
f"UPDATE {fq_table('memory_units')} SET proof_count = $1 WHERE id = $2 AND bank_id = $3",
proof_count,
observation_id,
bank_id,
)
else:
await conn.execute(
f"UPDATE {fq_table('memory_units')} SET source_memory_ids = $1, proof_count = $2 "
f"WHERE id = $3 AND bank_id = $4",
source_ids,
proof_count,
observation_id,
bank_id,
)
def _observation_mentioned_at(obs: TransferObservation) -> datetime | None:
"""event_date (NOT NULL) is derived from occurred_start or mentioned_at on
insert; fall back so the column stays populated for observations too."""
mentioned_at = obs.mentioned_at
if obs.occurred_start is None and mentioned_at is None:
mentioned_at = obs.event_date or datetime.now(UTC)
return mentioned_at
def _to_extracted_fact(fact: TransferFact) -> ExtractedFact:
"""Rebuild the retain pipeline's ExtractedFact from a serialized transfer fact."""
# event_date is NOT NULL in the schema and is derived from occurred_start or
# mentioned_at on insert. When neither is present, fall back to the carried
# event_date (or now) via mentioned_at so the column stays populated.
mentioned_at = fact.mentioned_at
if fact.occurred_start is None and mentioned_at is None:
mentioned_at = fact.event_date or datetime.now(UTC)
return ExtractedFact(
fact_text=fact.text,
fact_type=fact.fact_type,
entities=list(fact.entities),
occurred_start=fact.occurred_start,
occurred_end=fact.occurred_end,
where=None,
causal_relations=[
CausalRelation(relation_type=rel.relation_type, target_fact_index=rel.target_fact_index)
for rel in fact.causal_relations
],
content_index=0,
chunk_index=fact.chunk_index,
context=fact.context or "",
mentioned_at=mentioned_at,
metadata=dict(fact.metadata),
tags=list(fact.tags),
observation_scopes=fact.observation_scopes,
)
@@ -0,0 +1,138 @@
"""Serialization schema for the document transfer archive (manifest + per-document payloads).
The archive is a ZIP:
manifest.json -- TransferManifest
documents/000000.json -- TransferDocument (one file per document)
documents/000001.json
...
Documents are stored under a zero-padded index rather than their id so that
arbitrary document ids (which may contain path-unsafe characters) never leak
into archive entry names. The real id lives inside each payload.
"""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
# Bump when the archive layout changes in a backward-incompatible way.
SCHEMA_VERSION = 1
ObservationScopes = Literal["per_tag", "combined", "all_combinations"] | list[list[str]]
class TransferCausalRelation(BaseModel):
"""A causal edge from this fact to an earlier fact in the same document.
``target_fact_index`` is the ordinal of the target fact within the document's
``facts`` list (not a database id), so it survives transfer to a new bank.
"""
relation_type: str
target_fact_index: int
class TransferFact(BaseModel):
"""One extracted fact (memory unit) without its embedding or database id.
Everything here is reused verbatim on import except the embedding, which is
regenerated by the target bank's model, and the entity ids, which are
re-resolved against the target bank by canonical name.
"""
text: str
fact_type: str
context: str | None = None
# event_date is a fallback used only when both occurred_start and
# mentioned_at are absent, to satisfy the NOT NULL event_date column.
event_date: datetime | None = None
occurred_start: datetime | None = None
occurred_end: datetime | None = None
mentioned_at: datetime | None = None
metadata: dict[str, str] = Field(default_factory=dict)
tags: list[str] = Field(default_factory=list)
observation_scopes: ObservationScopes | None = None
# Ordinal of the source chunk within the document (parsed from chunk_id).
chunk_index: int | None = None
# Entity canonical names; re-resolved against the target bank on import.
entities: list[str] = Field(default_factory=list)
causal_relations: list[TransferCausalRelation] = Field(default_factory=list)
class TransferChunk(BaseModel):
"""A raw text chunk of the source document, reused verbatim."""
chunk_index: int
chunk_text: str
class TransferObservationSource(BaseModel):
"""A reference to a source fact of an observation, by document + ordinal.
Observations span documents and reference their source facts by unit id;
those ids don't survive transfer, so each source is carried as the
(document_id, fact_index) of the fact within the exported document set.
"""
document_id: str
fact_index: int
class TransferObservation(BaseModel):
"""A consolidated observation (``fact_type='observation'``).
Observations are bank-level (not tied to one document), carry no embedding
(re-generated on import) and no entity/link associations retrieval reaches
entities/links through their source facts. Only exported when explicitly
requested, and only when every source resolves within the archive.
"""
text: str
tags: list[str] = Field(default_factory=list)
event_date: datetime | None = None
occurred_start: datetime | None = None
occurred_end: datetime | None = None
mentioned_at: datetime | None = None
observation_scopes: ObservationScopes | None = None
proof_count: int = 1
sources: list[TransferObservationSource] = Field(default_factory=list)
class TransferDocument(BaseModel):
"""A single document plus its chunks and extracted facts."""
id: str
original_text: str | None = None
retain_params: dict | None = None
tags: list[str] = Field(default_factory=list)
created_at: datetime | None = None
chunks: list[TransferChunk] = Field(default_factory=list)
facts: list[TransferFact] = Field(default_factory=list)
class TransferManifest(BaseModel):
"""Top-level archive descriptor (``manifest.json``).
The bank-level fields default to a documents-only archive so older
document-only archives (and the document import path) keep parsing
unchanged; ``export_bank`` populates them for a whole-bank archive.
"""
schema_version: int = SCHEMA_VERSION
source_bank_id: str
exported_at: datetime | None = None
document_count: int = 0
fact_count: int = 0
observation_count: int = 0
# "documents" = doc/fact/observation subset; "bank" = whole-bank export
# (also carries bank config, mental models, directives, webhooks).
archive_type: Literal["documents", "bank"] = "documents"
mental_model_count: int = 0
directive_count: int = 0
webhook_count: int = 0
# True when --include-history carried audit_log / llm_requests.
includes_history: bool = False
@@ -40,6 +40,11 @@ class Tenant:
"""
schema: str
# Optional tenant identifier. When provided, background maintenance (e.g. the
# consolidation reconcile sweep) can build a RequestContext carrying this id so
# tenant-level config overrides are honored. Leave as None for single-tenant
# setups or extensions that do not key config by tenant id.
tenant_id: str | None = None
class TenantExtension(Extension, ABC):
+51 -4
View File
@@ -184,6 +184,8 @@ class MetricsCollectorBase:
input_tokens: int = 0,
output_tokens: int = 0,
success: bool = True,
cached_input_tokens: int = 0,
thoughts_tokens: int = 0,
):
"""
Record metrics for an LLM call.
@@ -193,9 +195,11 @@ class MetricsCollectorBase:
model: Model name
scope: Scope identifier (e.g., "memory", "reflect", "consolidation")
duration: Call duration in seconds
input_tokens: Number of input/prompt tokens
output_tokens: Number of output/completion tokens
input_tokens: Number of input/prompt tokens (total)
output_tokens: Number of output/completion tokens visible in candidates
success: Whether the call was successful
cached_input_tokens: Subset of input_tokens billed at the cached rate
thoughts_tokens: Reasoning tokens (billed as output, hidden from candidates)
"""
raise NotImplementedError
@@ -233,6 +237,8 @@ class NoOpMetricsCollector(MetricsCollectorBase):
input_tokens: int = 0,
output_tokens: int = 0,
success: bool = True,
cached_input_tokens: int = 0,
thoughts_tokens: int = 0,
):
"""No-op LLM call recording."""
pass
@@ -287,6 +293,27 @@ class MetricsCollector(MetricsCollectorBase):
name="hindsight.llm.calls.total", description="Total number of LLM API calls", unit="calls"
)
# Cached input tokens (subset of input_tokens billed at the cached rate).
# Useful for tracking prompt-cache hit-rate independently of total
# input volume. provider.scope.model labels matche llm_tokens_input.
self.llm_tokens_cached_input = self.meter.create_counter(
name="hindsight.llm.tokens.cached_input",
description="Number of cached input tokens (billed at cached rate) for LLM calls",
unit="tokens",
)
# Thinking / reasoning tokens (Gemini 2.5+ family). Billed at the
# output rate by the provider but invisible to candidates_token_count.
# Surfacing them as a distinct counter is required for honest cost
# attribution: a workload that "looks cheap" by output volume can be
# silently expensive if the model is doing long reasoning chains.
self.llm_tokens_thoughts = self.meter.create_counter(
name="hindsight.llm.tokens.thoughts",
description="Number of reasoning/thinking tokens emitted by the model "
"(billed as output but not surfaced in candidates)",
unit="tokens",
)
# HTTP request metrics
self.http_request_duration = self.meter.create_histogram(
name="hindsight.http.duration", description="Duration of HTTP requests in seconds", unit="s"
@@ -370,6 +397,8 @@ class MetricsCollector(MetricsCollectorBase):
input_tokens: int = 0,
output_tokens: int = 0,
success: bool = True,
cached_input_tokens: int = 0,
thoughts_tokens: int = 0,
):
"""
Record metrics for an LLM call.
@@ -379,9 +408,15 @@ class MetricsCollector(MetricsCollectorBase):
model: Model name
scope: Scope identifier (e.g., "memory", "reflect", "consolidation")
duration: Call duration in seconds
input_tokens: Number of input/prompt tokens
output_tokens: Number of output/completion tokens
input_tokens: Number of input/prompt tokens (total, including cached portion)
output_tokens: Number of output/completion tokens visible in candidates
success: Whether the call was successful
cached_input_tokens: Subset of input_tokens billed at the cached
rate (Gemini context caching). Defaults to 0 when caching is
disabled or the provider doesn't surface this field.
thoughts_tokens: Reasoning/thinking tokens (Gemini 2.5+ family).
Billed at the output rate but not counted in candidates.
Defaults to 0 for providers that don't emit thoughts.
"""
# Base attributes for all metrics
base_attributes = {
@@ -413,6 +448,18 @@ class MetricsCollector(MetricsCollectorBase):
}
self.llm_tokens_output.add(output_tokens, output_attributes)
if cached_input_tokens > 0:
self.llm_tokens_cached_input.add(
cached_input_tokens,
{**base_attributes, "token_bucket": get_token_bucket(cached_input_tokens)},
)
if thoughts_tokens > 0:
self.llm_tokens_thoughts.add(
thoughts_tokens,
{**base_attributes, "token_bucket": get_token_bucket(thoughts_tokens)},
)
@contextmanager
def record_http_request(self, method: str, endpoint: str, status_code_getter: Callable[[], int]):
"""
+52 -7
View File
@@ -273,6 +273,8 @@ class LLMSpanRecorder:
finish_reason: Optional[str] = None,
error: Optional[Exception] = None,
tool_calls: Optional[list[dict[str, Any]]] = None,
cached_tokens: int = 0,
**_extra: Any,
) -> None:
"""
Record a completed LLM call as a span with GenAI semantic conventions.
@@ -293,6 +295,8 @@ class LLMSpanRecorder:
finish_reason: Reason the model stopped (stop, length, tool_calls, etc.)
error: Exception if call failed
tool_calls: List of tool calls made (for function calling)
cached_tokens: Cached/cache-read prompt tokens, when reported by the provider.
_extra: Tolerated forward-compatible kwargs from other recorders.
"""
try:
# Map provider name to GenAI semantic convention
@@ -326,6 +330,8 @@ class LLMSpanRecorder:
span.set_attribute(GenAIAttributes.RESPONSE_MODEL, model)
span.set_attribute(GenAIAttributes.USAGE_INPUT_TOKENS, input_tokens)
span.set_attribute(GenAIAttributes.USAGE_OUTPUT_TOKENS, output_tokens)
if cached_tokens:
span.set_attribute("gen_ai.usage.cached_tokens", cached_tokens)
# Add custom attributes for Hindsight context
span.set_attribute("hindsight.scope", scope)
@@ -460,22 +466,61 @@ class NoOpLLMSpanRecorder:
pass
# Global span recorder instance
class CompositeSpanRecorder:
"""Fans out ``record_llm_call`` to every registered recorder.
This lets multiple GenAI consumers observe the same LLM calls e.g. the
OpenTelemetry span exporter and the per-bank DB tracer through the single
``record_llm_call`` chokepoint each provider already calls. A failure in one
recorder never affects the others or the LLM call itself.
"""
def __init__(self) -> None:
self._recorders: list[Any] = []
def register(self, recorder: Any) -> None:
if recorder not in self._recorders:
self._recorders.append(recorder)
def unregister(self, recorder: Any) -> None:
if recorder in self._recorders:
self._recorders.remove(recorder)
def record_llm_call(self, **kwargs: Any) -> None:
for recorder in self._recorders:
try:
recorder.record_llm_call(**kwargs)
except Exception as e: # never let one recorder break others
logger.debug(f"Span recorder {type(recorder).__name__} failed: {e}", exc_info=True)
# Global composite recorder — always present; fans out to whatever is registered.
_composite_recorder = CompositeSpanRecorder()
# Backward-compat reference to the OTel recorder (if created).
_span_recorder: Optional[LLMSpanRecorder] = None
def get_span_recorder() -> LLMSpanRecorder | NoOpLLMSpanRecorder:
"""Get the global span recorder (NoOp if tracing disabled)."""
if _span_recorder is None:
return NoOpLLMSpanRecorder()
return _span_recorder
def get_span_recorder() -> CompositeSpanRecorder:
"""Get the global composite span recorder (fans out to all registered recorders)."""
return _composite_recorder
def register_span_recorder(recorder: Any) -> None:
"""Register an additional GenAI recorder (e.g. the per-bank DB tracer)."""
_composite_recorder.register(recorder)
def unregister_span_recorder(recorder: Any) -> None:
"""Remove a previously registered recorder."""
_composite_recorder.unregister(recorder)
def create_span_recorder() -> LLMSpanRecorder:
"""Create and set the global span recorder."""
"""Create and register the OpenTelemetry span recorder."""
global _span_recorder
tracer = get_tracer()
if tracer is None:
raise RuntimeError("Tracing not initialized. Call initialize_tracing() first.")
_span_recorder = LLMSpanRecorder(tracer)
register_span_recorder(_span_recorder)
return _span_recorder
+9 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api-slim"
version = "0.7.1"
version = "0.7.2"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -96,6 +96,13 @@ local-llm = [
"llama-cpp-python[server]>=0.3.0",
"huggingface-hub>=0.20.0",
]
local-onnx = [
# In-process ONNX Runtime embeddings without an Ollama/TEI sidecar
"onnxruntime>=1.17.0",
"transformers>=4.53.0",
"huggingface-hub>=0.20.0",
"numpy>=1.26.0",
]
embedded-db = [
"pg0-embedded>=0.14.2",
]
@@ -103,7 +110,7 @@ oracle = [
"oracledb>=2.5.0",
]
all = [
"hindsight-api-slim[local-ml,embedded-db]",
"hindsight-api-slim[local-ml,local-onnx,embedded-db]",
]
test = [
"pytest>=7.0.0",
+10
View File
@@ -20,6 +20,16 @@ from hindsight_api.pg0 import EmbeddedPostgres
DEFAULT_PG0_INSTANCE_NAME = "hindsight-test"
DEFAULT_PG0_PORT = int(os.environ.get("HINDSIGHT_TEST_PG_PORT", "5556"))
# Keep the background MaintenanceLoop from auto-starting during tests. In
# production it sweeps retention and re-schedules consolidation, but its timers
# would race shared-pg0 test data (e.g. delete llm_requests/audit_log rows a test
# just inserted). Disabling the reconcile interval and llm-trace retention — with
# audit retention already off by default — leaves no job enabled, so the loop
# never starts. Tests that exercise it call MaintenanceLoop methods
# (_run_reconcile / _purge_expired) directly.
os.environ.setdefault("HINDSIGHT_API_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS", "0")
os.environ.setdefault("HINDSIGHT_API_LLM_TRACE_RETENTION_DAYS", "-1")
# Load environment variables from .env at the start of test session
def pytest_configure(config):
@@ -103,6 +103,11 @@ async def test_small_async_batch_no_splitting(memory, request_context):
assert status["result_metadata"]["num_sub_batches"] == 1 # Single sub-batch
assert len(status["child_operations"]) == 1
assert status["child_operations"][0]["status"] == "completed"
child_meta = await _child_metadata(memory, bank_id, operation_id, request_context)
assert child_meta["unit_ids_count"] > 0
assert child_meta["extraction_errors_count"] == 0
assert status["result_metadata"]["unit_ids_count"] == child_meta["unit_ids_count"]
assert status["result_metadata"]["extraction_errors_count"] == 0
@pytest.mark.asyncio
@@ -166,6 +171,19 @@ async def test_large_async_batch_auto_splits(memory, request_context):
# Parent status should be aggregated as "completed"
assert parent_status["status"] == "completed"
child_unit_counts = []
for child in child_ops:
child_status = await memory.get_operation_status(
bank_id=bank_id,
operation_id=child["operation_id"],
request_context=request_context,
)
child_meta = child_status["result_metadata"]
assert child_meta["unit_ids_count"] > 0
assert child_meta["extraction_errors_count"] == 0
child_unit_counts.append(child_meta["unit_ids_count"])
assert parent_status["result_metadata"]["unit_ids_count"] == sum(child_unit_counts)
assert parent_status["result_metadata"]["extraction_errors_count"] == 0
@pytest.mark.asyncio
@@ -461,6 +479,42 @@ async def _child_metadata(memory, bank_id: str, parent_operation_id: str, reques
return child["result_metadata"]
@pytest.mark.asyncio
async def test_retain_outcome_metadata_records_zero_counts(memory, request_context, monkeypatch):
"""Completed retain operations expose explicit zero outcome counters."""
from hindsight_api.engine.response_models import TokenUsage
from hindsight_api.engine.retain import fact_extraction
async def empty_extract_facts_from_contents(
*args: object, **kwargs: object
) -> tuple[list[object], list[object], TokenUsage]:
return [], [], TokenUsage()
monkeypatch.setattr(fact_extraction, "extract_facts_from_contents", empty_extract_facts_from_contents)
bank_id = "test_retain_outcome_zero_counts"
result = await memory.submit_async_retain(
bank_id=bank_id,
contents=[{"content": "No extracted facts for this item."}],
request_context=request_context,
)
await asyncio.sleep(0.2)
parent = await memory.get_operation_status(
bank_id=bank_id,
operation_id=result["operation_id"],
request_context=request_context,
)
child_meta = await _child_metadata(memory, bank_id, result["operation_id"], request_context)
assert child_meta["unit_ids_count"] == 0
assert child_meta["extraction_errors_count"] == 0
assert "extraction_errors_sample" not in child_meta
assert parent["result_metadata"]["unit_ids_count"] == 0
assert parent["result_metadata"]["extraction_errors_count"] == 0
assert "extraction_errors_sample" not in parent["result_metadata"]
@pytest.mark.asyncio
async def test_retain_records_user_provided_document_ids(memory, request_context):
"""User-supplied document_ids land in child op result_metadata.document_ids."""
@@ -1060,3 +1114,67 @@ async def test_submit_async_batch_retain_rolls_back_parent_on_child_failure(
f"{[(r['operation_type'], r['status'], r['task_payload'] is not None) for r in rows]}. "
"The parent INSERT must be transactionally coupled to the child INSERTs."
)
@pytest.mark.asyncio
async def test_submit_async_batch_retain_creates_missing_bank(memory, request_context, monkeypatch):
"""First async retain to a new bank lazily creates the bank (async_operations
has an FK to banks) instead of raising a constraint error."""
async def noop_submit_task(_task_dict):
return None
monkeypatch.setattr(memory._task_backend, "submit_task", noop_submit_task)
bank_id = f"test_batch_newbank_{uuid.uuid4().hex[:8]}"
pool = await memory._get_pool()
await memory.submit_async_retain(
bank_id=bank_id,
contents=[{"content": "Alice works at Google.", "document_id": "doc1"}],
request_context=request_context,
)
bank = await pool.fetchrow("SELECT bank_id FROM banks WHERE bank_id = $1", bank_id)
assert bank is not None, "submit_async_retain should have lazily created the bank"
@pytest.mark.asyncio
async def test_submit_async_batch_retain_rolls_back_missing_bank_on_child_failure(
memory_no_llm_verify, request_context, monkeypatch
):
"""The lazy bank-create shares the parent+child transaction. When the child
loop fails for a bank that did not previously exist, the freshly-created bank
must roll back together with the operation rows no orphan bank."""
import hindsight_api.engine.memory_engine as me
from hindsight_api.engine.memory_engine import count_tokens
bank_id = f"test_batch_bank_rollback_{uuid.uuid4().hex[:8]}"
pool = await memory_no_llm_verify._get_pool()
# Intentionally do NOT pre-create the bank — it must be created (and then
# rolled back) inside submit_async_retain's transaction.
large_content = "The quick brown fox jumps over the lazy dog. " * 500
contents = [{"content": large_content + f" item {i}", "document_id": f"doc{i}"} for i in range(2)]
assert sum(count_tokens(item["content"]) for item in contents) > 10_000
real_class = me.BatchRetainChildMetadata
call_count = {"n": 0}
def failing_child_metadata(*args, **kwargs):
call_count["n"] += 1
if call_count["n"] == 2:
raise RuntimeError("Simulated child-step failure mid-batch")
return real_class(*args, **kwargs)
monkeypatch.setattr(me, "BatchRetainChildMetadata", failing_child_metadata)
with pytest.raises(RuntimeError, match="Simulated child-step failure"):
await memory_no_llm_verify.submit_async_retain(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
bank = await pool.fetchrow("SELECT bank_id FROM banks WHERE bank_id = $1", bank_id)
assert bank is None, "the lazily-created bank must roll back with the failed operation inserts"
@@ -1,6 +1,6 @@
"""Unit tests for async retain tag propagation."""
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -56,19 +56,18 @@ async def test_submit_async_retain_includes_document_tags_in_task_payload():
contents = [{"content": "Async retain payload test."}]
document_tags = ["scope:tools", "user:alice"]
# Return (profile, created=False) so the default-template-on-create hook is skipped.
with patch(
"hindsight_api.engine.memory_engine.bank_utils.get_or_create_bank_profile",
new_callable=AsyncMock,
return_value=(MagicMock(), False),
):
result = await MemoryEngine.submit_async_retain(
engine,
bank_id="bank-1",
contents=contents,
document_tags=document_tags,
request_context=request_context,
)
# Stub the lazy bank-create/default-template hook to a no-op (created=False)
# so the inline transaction path runs against the mock connection without
# real DB work. The hook itself is covered by dedicated tests.
engine._ensure_bank_exists = AsyncMock(return_value=False)
result = await MemoryEngine.submit_async_retain(
engine,
bank_id="bank-1",
contents=contents,
document_tags=document_tags,
request_context=request_context,
)
# Check result structure
assert "operation_id" in result
+146 -6
View File
@@ -5,6 +5,7 @@ Covers the new fields exposed by GET /v1/default/banks/{bank_id}/stats
(operations_by_status) and the new endpoint
GET /v1/default/banks/{bank_id}/stats/memories-timeseries.
"""
import uuid
from datetime import datetime
@@ -83,9 +84,7 @@ async def test_bank_stats_exposes_operations_by_status(api_client, test_bank_id)
("90d", 90, "day"),
],
)
async def test_memories_timeseries_periods(
api_client, test_bank_id, period, expected_count, expected_trunc
):
async def test_memories_timeseries_periods(api_client, test_bank_id, period, expected_count, expected_trunc):
"""Every period must return the full expected bucket count and trunc."""
try:
response = await api_client.post(
@@ -139,9 +138,7 @@ async def test_memories_timeseries_invalid_period_falls_back(api_client, test_ba
@pytest.mark.asyncio
async def test_memories_timeseries_empty_bank_returns_zero_filled_buckets(
api_client, test_bank_id
):
async def test_memories_timeseries_empty_bank_returns_zero_filled_buckets(api_client, test_bank_id):
"""A bank with no memories must still return the full zero-filled bucket set."""
try:
response = await api_client.get(
@@ -242,3 +239,146 @@ async def test_list_memories_filter_by_consolidation_state_rejects_unknown(api_c
assert response.status_code == 400
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
async def test_bank_stats_link_counts_have_no_join(api_client, test_bank_id):
"""link_counts must be populated; the deprecated breakdown fields must be empty.
Confirms the simplified single-table aggregation still produces the totals
the UI reads (`links_by_link_type`) without the historical
memory_linksmemory_units join that powered the 2D `links_breakdown` no
consumer reads.
"""
try:
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories",
json={"items": [{"content": "Carol leads platform engineering.", "context": "team"}]},
)
assert response.status_code == 200
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
assert response.status_code == 200
stats = response.json()
# link totals must still come back so the UI overview cards render.
assert isinstance(stats["links_by_link_type"], dict)
assert stats["total_links"] >= 0
# Deprecated breakdown fields stay in the response shape but are empty.
assert stats["links_breakdown"] == {}
assert stats["links_by_fact_type"] == {}
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@pytest.mark.asyncio
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
try:
await _insert_memory(memory, test_bank_id, "Headed for consolidation.", failed=False)
await _insert_memory(memory, test_bank_id, "Also pending.", failed=True)
freshness = await memory.get_bank_freshness(
test_bank_id,
request_context=RequestContext(internal=True),
)
assert set(freshness.keys()) == {
"last_consolidated_at",
"pending_consolidation",
"failed_consolidation",
}
assert freshness["pending_consolidation"] >= 2
assert freshness["failed_consolidation"] >= 1
finally:
await memory._bank_stats_cache.clear()
async with memory._pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", test_bank_id)
@pytest.mark.asyncio
async def test_reflect_uses_freshness_not_bank_stats(memory, test_bank_id):
"""reflect() must call the cheap freshness query, not get_bank_stats.
Counts calls to `_compute_bank_stats` (the heavy loader) during a reflect
invocation; it must stay at zero reflect should route through
`get_bank_freshness` instead.
"""
from hindsight_api.extensions import RequestContext
try:
# Seed a single memory so reflect has something to inspect.
await _insert_memory(memory, test_bank_id, "Reflect seed.", failed=False)
compute_calls = 0
original_compute = memory._compute_bank_stats
async def counting_compute(bank_id: str):
nonlocal compute_calls
compute_calls += 1
return await original_compute(bank_id)
memory._compute_bank_stats = counting_compute # type: ignore[method-assign]
try:
await memory._bank_stats_cache.clear()
try:
await memory.reflect(
test_bank_id,
"What do you know about this bank?",
request_context=RequestContext(internal=True),
)
except Exception:
# reflect may fail without a configured LLM in this test env;
# we only care that it did not invoke the heavy stats loader
# before failing.
pass
assert compute_calls == 0
finally:
memory._compute_bank_stats = original_compute # type: ignore[method-assign]
finally:
await memory._bank_stats_cache.clear()
async with memory._pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", test_bank_id)
@pytest.mark.asyncio
async def test_bank_stats_served_from_cache_on_repeat_call(api_client, memory, test_bank_id):
"""A second /stats call within the TTL must not re-run the aggregations.
The cache layer wraps the DB-heavy `_compute_bank_stats` body; counting
its invocations is the cleanest way to prove the wiring works without
relying on timing.
"""
try:
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories",
json={"items": [{"content": "Bob is a project manager.", "context": "team"}]},
)
assert response.status_code == 200
original = memory._compute_bank_stats
call_count = 0
async def counting_compute(bank_id: str):
nonlocal call_count
call_count += 1
return await original(bank_id)
# Make sure no stale entry exists from prior test ordering.
await memory._bank_stats_cache.clear()
memory._compute_bank_stats = counting_compute # type: ignore[method-assign]
try:
first = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
second = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
assert first.status_code == 200
assert second.status_code == 200
assert first.json() == second.json()
assert call_count == 1
finally:
memory._compute_bank_stats = original # type: ignore[method-assign]
await memory._bank_stats_cache.clear()
finally:
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
@@ -0,0 +1,201 @@
"""Unit tests for `BankStatsCache` — TTL, eviction, and concurrent coalescing.
These tests don't touch the database; they exercise the cache wrapper
directly so the semantics are checked in isolation from `MemoryEngine`.
"""
from __future__ import annotations
import asyncio
from typing import Any
import pytest
from hindsight_api.engine.bank_stats_cache import BankStatsCache
def make_loader(return_value: dict[str, Any]) -> tuple[Any, list[int]]:
"""Returns (loader_fn, call_count_list). `call_count_list[0]` is the count."""
calls = [0]
async def loader() -> dict[str, Any]:
calls[0] += 1
return return_value
return loader, calls
@pytest.mark.asyncio
async def test_cache_disabled_passes_through() -> None:
cache = BankStatsCache(ttl_seconds=0, max_entries=100)
loader, calls = make_loader({"v": 1})
for _ in range(3):
result = await cache.get_or_load("schema", "bank", loader)
assert result == {"v": 1}
assert calls[0] == 3
@pytest.mark.asyncio
async def test_cache_serves_hits_within_ttl() -> None:
cache = BankStatsCache(ttl_seconds=60, max_entries=100)
loader, calls = make_loader({"v": 1})
first = await cache.get_or_load("schema", "bank", loader)
second = await cache.get_or_load("schema", "bank", loader)
assert first == second == {"v": 1}
assert calls[0] == 1
@pytest.mark.asyncio
async def test_cache_reloads_after_ttl_expires(monkeypatch) -> None:
cache = BankStatsCache(ttl_seconds=0.05, max_entries=100)
loader, calls = make_loader({"v": 1})
fake_time = [1000.0]
monkeypatch.setattr(cache, "_now", lambda: fake_time[0])
await cache.get_or_load("schema", "bank", loader)
fake_time[0] += 0.1 # advance past TTL
await cache.get_or_load("schema", "bank", loader)
assert calls[0] == 2
@pytest.mark.asyncio
async def test_cache_isolates_by_schema_and_bank() -> None:
cache = BankStatsCache(ttl_seconds=60, max_entries=100)
loader, calls = make_loader({"v": 1})
await cache.get_or_load("schema_a", "bank", loader)
await cache.get_or_load("schema_b", "bank", loader)
await cache.get_or_load("schema_a", "other", loader)
# 3 distinct keys → 3 loader calls.
assert calls[0] == 3
@pytest.mark.asyncio
async def test_concurrent_misses_are_coalesced() -> None:
"""6 concurrent callers on the same cold key must trigger exactly one loader."""
cache = BankStatsCache(ttl_seconds=60, max_entries=100)
calls = [0]
started = asyncio.Event()
release = asyncio.Event()
async def slow_loader() -> dict[str, Any]:
calls[0] += 1
started.set()
await release.wait()
return {"v": calls[0]}
tasks = [asyncio.create_task(cache.get_or_load("schema", "bank", slow_loader)) for _ in range(6)]
await started.wait()
# All other tasks should now be queued behind the in-flight loader.
release.set()
results = await asyncio.gather(*tasks)
assert calls[0] == 1
assert all(r == {"v": 1} for r in results)
@pytest.mark.asyncio
async def test_loader_exception_does_not_poison_cache() -> None:
cache = BankStatsCache(ttl_seconds=60, max_entries=100)
calls = [0]
async def flaky_loader() -> dict[str, Any]:
calls[0] += 1
if calls[0] == 1:
raise RuntimeError("boom")
return {"v": calls[0]}
with pytest.raises(RuntimeError, match="boom"):
await cache.get_or_load("schema", "bank", flaky_loader)
# Second call should still attempt the loader (cache wasn't populated).
result = await cache.get_or_load("schema", "bank", flaky_loader)
assert result == {"v": 2}
assert calls[0] == 2
@pytest.mark.asyncio
async def test_concurrent_loader_exception_propagates_to_waiters() -> None:
cache = BankStatsCache(ttl_seconds=60, max_entries=100)
started = asyncio.Event()
release = asyncio.Event()
async def failing_loader() -> dict[str, Any]:
started.set()
await release.wait()
raise RuntimeError("loader failed")
tasks = [asyncio.create_task(cache.get_or_load("schema", "bank", failing_loader)) for _ in range(3)]
await started.wait()
release.set()
results = await asyncio.gather(*tasks, return_exceptions=True)
assert all(isinstance(r, RuntimeError) for r in results)
@pytest.mark.asyncio
async def test_lru_eviction_respects_max_entries() -> None:
cache = BankStatsCache(ttl_seconds=60, max_entries=2)
async def loader_for(value: int):
async def _loader() -> dict[str, Any]:
return {"v": value}
return _loader
await cache.get_or_load("s", "a", await loader_for(1))
await cache.get_or_load("s", "b", await loader_for(2))
# Touch "a" so it's most-recently-used.
await cache.get_or_load("s", "a", await loader_for(99))
# Insert "c" — should evict "b" (the LRU), not "a".
await cache.get_or_load("s", "c", await loader_for(3))
# "a" is still cached (loader for "a" with value=99 must NOT be called again).
miss_check_calls = [0]
async def should_not_run() -> dict[str, Any]:
miss_check_calls[0] += 1
return {"v": -1}
cached_a = await cache.get_or_load("s", "a", should_not_run)
assert cached_a == {"v": 1}
assert miss_check_calls[0] == 0
# "b" was evicted; the loader must run on the next get.
new_b_calls = [0]
async def new_b() -> dict[str, Any]:
new_b_calls[0] += 1
return {"v": 200}
fetched_b = await cache.get_or_load("s", "b", new_b)
assert fetched_b == {"v": 200}
assert new_b_calls[0] == 1
@pytest.mark.asyncio
async def test_invalidate_drops_entry() -> None:
cache = BankStatsCache(ttl_seconds=60, max_entries=100)
loader, calls = make_loader({"v": 1})
await cache.get_or_load("schema", "bank", loader)
await cache.invalidate("schema", "bank")
await cache.get_or_load("schema", "bank", loader)
assert calls[0] == 2
@pytest.mark.asyncio
async def test_clear_drops_all_entries() -> None:
cache = BankStatsCache(ttl_seconds=60, max_entries=100)
loader, calls = make_loader({"v": 1})
await cache.get_or_load("s", "a", loader)
await cache.get_or_load("s", "b", loader)
assert calls[0] == 2
await cache.clear()
await cache.get_or_load("s", "a", loader)
await cache.get_or_load("s", "b", loader)
assert calls[0] == 4
+104 -7
View File
@@ -7,21 +7,23 @@ Tests cover:
- Hard error when provider doesn't support the batch API (no silent fallback)
- Worker recovery on restart
"""
import pytest
import asyncio
import logging
import json
import logging
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from hindsight_api import RequestContext
from hindsight_api.engine.retain.fact_extraction import (
extract_facts_from_contents_batch_api,
extract_facts_from_contents,
RetainContent,
)
from hindsight_api.config import HindsightConfig
from hindsight_api.engine.llm_wrapper import create_llm_provider
from hindsight_api.engine.retain.fact_extraction import (
RetainContent,
extract_facts_from_contents,
extract_facts_from_contents_batch_api,
)
from hindsight_api.worker.poller import WorkerPoller
logger = logging.getLogger(__name__)
@@ -331,6 +333,101 @@ async def test_batch_api_crash_recovery(mock_llm_config, test_contents, hindsigh
pass
@pytest.mark.asyncio
async def test_batch_api_records_non_fatal_extraction_errors(
mock_llm_config, test_contents, hindsight_config, memory, request_context
):
"""Batch API skipped chunks are surfaced in operation result_metadata."""
bank_id = f"test_batch_errors_{datetime.now(timezone.utc).timestamp()}"
operation_id = str(uuid.uuid4())
try:
await memory.get_bank_profile(bank_id, request_context=request_context)
pool = memory._pool
schema = request_context.tenant_id
from hindsight_api.engine.task_backend import fq_table
table = fq_table("async_operations", schema)
await pool.execute(
f"""
INSERT INTO {table} (operation_id, operation_type, bank_id, status, result_metadata)
VALUES ($1, 'retain', $2, 'processing', $3::jsonb)
""",
operation_id,
bank_id,
json.dumps({}),
)
batch_id = "batch_partial_errors"
mock_llm_config._provider_impl.supports_batch_api = AsyncMock(return_value=True)
mock_llm_config._provider_impl.submit_batch = AsyncMock(return_value={"batch_id": batch_id})
mock_llm_config._provider_impl.get_batch_status = AsyncMock(
return_value={
"status": "completed",
"request_counts": {"total": 2, "completed": 2, "failed": 0},
}
)
mock_llm_config._provider_impl.retrieve_batch_results = AsyncMock(
return_value=[
{
"custom_id": "chunk_0",
"response": {
"body": {
"choices": [
{
"message": {
"content": json.dumps({
"facts": [
{
"what": "Alice is a senior software engineer",
"when": "present",
"where": "TechCorp",
"who": "Alice",
"why": "Background",
"fact_type": "world",
"fact_kind": "conversation",
}
]
})
}
}
],
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
}
},
}
]
)
facts, chunks, usage = await extract_facts_from_contents_batch_api(
contents=test_contents,
llm_config=mock_llm_config,
agent_name="test_agent",
config=hindsight_config,
pool=pool,
operation_id=operation_id,
schema=schema,
)
assert len(facts) == 1
assert len(chunks) == 2
assert chunks[1].fact_count == 0
assert usage.total_tokens == 150
row = await pool.fetchrow(f"SELECT result_metadata FROM {table} WHERE operation_id = $1", operation_id)
metadata = json.loads(row["result_metadata"]) if isinstance(row["result_metadata"], str) else row["result_metadata"]
assert metadata["batch_id"] == batch_id
assert metadata["extraction_errors_count"] == 1
assert metadata["extraction_errors_sample"] == ["chunk_1: missing batch result"]
finally:
try:
await memory.delete_bank(bank_id, request_context=request_context)
except Exception:
pass
@pytest.mark.asyncio
async def test_batch_api_raises_for_unsupported_provider(mock_llm_config, test_contents, hindsight_config):
"""Batch extraction must surface a hard error (not silently fall back) when
@@ -22,6 +22,7 @@ def setup_test_env():
"HINDSIGHT_API_LLM_PROVIDER",
"HINDSIGHT_API_LLM_MODEL",
"HINDSIGHT_API_LLM_REASONING_EFFORT",
"HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY",
"HINDSIGHT_API_DATABASE_URL",
"HINDSIGHT_API_MIGRATION_DATABASE_URL",
]
@@ -103,6 +104,26 @@ def test_valid_retain_config_succeeds():
assert config.retain_chunk_size == 3000
def test_semantic_min_similarity_reads_from_env():
"""Semantic retrieval min similarity can be configured at the server level."""
from hindsight_api.config import HindsightConfig
os.environ["HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY"] = "0.58"
config = HindsightConfig.from_env()
assert config.semantic_min_similarity == 0.58
def test_semantic_min_similarity_must_be_between_zero_and_one():
"""Invalid semantic min similarity fails fast during configuration loading."""
from hindsight_api.config import HindsightConfig
os.environ["HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY"] = "1.5"
with pytest.raises(ValueError, match="semantic_min_similarity"):
HindsightConfig.from_env()
def test_log_config_masks_database_urls(caplog):
"""Config startup logs must not expose database credentials."""
from hindsight_api.config import HindsightConfig
@@ -376,3 +397,48 @@ def test_llm_reasoning_effort_loaded_from_env(monkeypatch):
config = HindsightConfig.from_env()
assert config.llm_reasoning_effort == "xhigh"
# ---------------------------------------------------------------------------
# Recall candidate gating (BM25 score floor + per-source cap) — issue #1707
# ---------------------------------------------------------------------------
def test_bm25_min_score_defaults_to_zero(monkeypatch):
from hindsight_api.config import HindsightConfig
monkeypatch.delenv("HINDSIGHT_API_BM25_MIN_SCORE", raising=False)
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.bm25_min_score == 0.0
def test_bm25_min_score_loaded_from_env(monkeypatch):
from hindsight_api.config import HindsightConfig
monkeypatch.setenv("HINDSIGHT_API_BM25_MIN_SCORE", "1.5")
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.bm25_min_score == 1.5
def test_recall_max_candidates_per_source_defaults_to_disabled(monkeypatch):
from hindsight_api.config import HindsightConfig
monkeypatch.delenv("HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE", raising=False)
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.recall_max_candidates_per_source == 0
def test_recall_max_candidates_per_source_loaded_from_env(monkeypatch):
from hindsight_api.config import HindsightConfig
monkeypatch.setenv("HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_SOURCE", "150")
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.recall_max_candidates_per_source == 150
+58 -4
View File
@@ -464,7 +464,7 @@ class TestConsolidationIntegration:
async with memory._pool.acquire() as conn:
observations = await conn.fetch(
"""
SELECT id, text, source_memory_ids, history
SELECT id, text, source_memory_ids
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
""",
@@ -3040,10 +3040,13 @@ def _make_mock_llm_one_obs_per_fact():
def callback(messages, scope):
if scope != "consolidation":
return _ConsolidationBatchResponse()
# Parse all fact UUIDs from the prompt — one create per fact
# Parse all fact UUIDs from the prompt — one create per fact. Read only
# the user message(s): consolidation sends the facts there, while the
# stable (cacheable) system message carries example UUIDs in its OUTPUT
# FORMAT samples that must not be mistaken for real facts.
import re
prompt = messages[0]["content"] if messages else ""
prompt = "\n".join(m.get("content", "") for m in messages if m.get("role") == "user")
fact_ids = re.findall(r"\[([0-9a-f-]{36})\]", prompt)
creates = [_CreateAction(text=f"Observation about fact {fid[:8]}", source_fact_ids=[fid]) for fid in fact_ids]
return _ConsolidationBatchResponse(creates=creates)
@@ -3145,7 +3148,9 @@ async def test_max_observations_per_scope_allows_updates_at_capacity(memory: Mem
call_count += 1
import re
prompt = messages[0]["content"] if messages else ""
# Facts live in the user message; the system message (stable, cached)
# carries example UUIDs in its OUTPUT samples — read user only.
prompt = "\n".join(m.get("content", "") for m in messages if m.get("role") == "user")
fact_ids = re.findall(r"\[([0-9a-f-]{36})\]", prompt)
if call_count == 1 and fact_ids:
# First call: create an observation
@@ -3512,3 +3517,52 @@ async def test_enable_auto_consolidation_flag(memory: MemoryEngine, request_cont
finally:
memory._config_resolver._global_config = original_global_config
await memory.delete_bank(bank_id, request_context=request_context)
def test_consolidation_prompt_split_is_cacheable_and_complete():
"""The split consolidation prompt: bank-agnostic system prefix + per-batch user.
The system prefix must be byte-identical across batches AND across banks (the
property that lets a single Gemini context cache serve every bank), carry only
stable instructions, and the per-batch/per-bank data (mission, facts,
observations, capacity note) must live in the user message never in the
cached prefix.
"""
from hindsight_api.engine.consolidation.prompts import (
build_consolidation_input,
build_consolidation_system_prompt,
)
sys_prompt = build_consolidation_system_prompt()
# Byte-stable across calls and independent of any mission → one cache for all banks.
assert sys_prompt == build_consolidation_system_prompt()
# Instructions only: no per-batch placeholders leaked into the prefix.
assert "{facts_text}" not in sys_prompt
assert "{observations_text}" not in sys_prompt
# JSON examples are unescaped (single braces), i.e. .format() ran.
assert '{"creates"' in sys_prompt
assert "{{" not in sys_prompt
# The stable observation-format boilerplate lives in the cached prefix.
assert "proof_count" in sys_prompt
# Two banks with DIFFERENT missions share the identical cached prefix; the
# mission rides in the per-batch user message instead.
user_a = build_consolidation_input(
facts_text="[id-a] Fact A.", observations_text="[]", observations_mission="Track widgets."
)
user_b = build_consolidation_input(
facts_text="[id-b] Fact B.", observations_text="[]", observations_mission="Track gadgets."
)
assert "Track widgets." in user_a
assert "Track widgets." not in sys_prompt # mission NOT in the cached prefix
assert "Fact A." in user_a
assert user_a != user_b
# The format boilerplate is NOT re-sent per batch (it's in the cached prefix).
assert "proof_count" not in user_a
# The capacity note is per-batch too — kept out of the cached prefix.
capped = build_consolidation_input(
facts_text="[id] F.", observations_text="[]", observation_capacity_note="OBSERVATION LIMIT REACHED"
)
assert "OBSERVATION LIMIT REACHED" in capped
assert "OBSERVATION LIMIT REACHED" not in sys_prompt
@@ -0,0 +1,259 @@
"""Deterministic unit tests for the consolidation duplicate-create guard.
These exercise the dedup decision directly (no LLM, no DB), so they reliably
guard the fix in CI unlike the real-LLM integration test, which only triggers
the path stochastically.
"""
import types
import uuid
from dataclasses import dataclass
from unittest.mock import AsyncMock, patch
from hindsight_api.engine.consolidation.consolidator import (
_dedup_active,
_dedup_reconcile_create,
_dedup_reconcile_update,
_DedupDecision,
_duplicate_create_target,
_norm_obs_text,
)
from hindsight_api.engine.search.types import RetrievalResult
@dataclass
class _FakeObs:
id: str
text: str
def _shown(*observations: _FakeObs) -> dict[str, _FakeObs]:
return {_norm_obs_text(o.text): o for o in observations}
def test_norm_obs_text_collapses_whitespace_preserves_case() -> None:
# Whitespace (incl. newlines) collapses; case is preserved.
assert _norm_obs_text(" The User likes BASIL.\n") == "The User likes BASIL."
assert _norm_obs_text(None) == ""
def test_create_matching_shown_observation_is_duplicate() -> None:
shown = _shown(_FakeObs(id="11111111-aaaa", text="User waters the herbs early in the morning."))
# Same text with only-whitespace differences still matches.
target = _duplicate_create_target("User waters the herbs early in the morning.", shown, set())
assert target is not None
assert target.startswith("shown observation 11111111")
def test_create_differing_only_in_case_is_not_duplicate() -> None:
# Case-folding would lose information (e.g. acronyms), so a case-only difference
# is treated as novel rather than silently dropped.
shown = _shown(_FakeObs(id="22222222-bbbb", text="The user prefers TLS."))
assert _duplicate_create_target("The user prefers tls.", shown, set()) is None
def test_create_matching_inresponse_update_is_duplicate() -> None:
update_texts = {_norm_obs_text("Mint is kept in its own separate bed.")}
target = _duplicate_create_target("Mint is kept in its own separate bed.", {}, update_texts)
assert target == "an UPDATE in this response"
def test_novel_create_is_not_duplicate() -> None:
shown = _shown(_FakeObs(id="22222222-bbbb", text="User waters the herbs early in the morning."))
assert _duplicate_create_target("Rosemary is drought-tolerant.", shown, set()) is None
assert _duplicate_create_target("", {}, set()) is None
# ── semantic dedup (_dedup_reconcile_create) ──────────────────────────────────
#
# Mocks the embedder, the obs-anchored ANN probe, and the LLM so the decision logic is
# tested without a DB or a real model.
_TWIN_ID = "33333333-3333-4333-8333-333333333333"
def _obs(text: str, sim: float, oid: str = _TWIN_ID) -> RetrievalResult:
return RetrievalResult(id=oid, text=text, fact_type="observation", similarity=sim)
def _ctx(threshold: float = 0.97):
"""Return (kwargs, conn_mock, llm_mock) for a _dedup_reconcile_create call."""
conn = AsyncMock()
llm = types.SimpleNamespace(call=AsyncMock())
kwargs = dict(
conn=conn,
memory_engine=types.SimpleNamespace(embeddings=object()),
bank_id="bank1",
config=types.SimpleNamespace(consolidation_dedup_threshold=threshold),
dedup_llm_config=llm,
create_text="YouTube content in Uzbek is very rich.",
create_source_ids=[uuid.uuid4()],
tags=["t1"],
)
return kwargs, conn, llm
def _patch_probe(results):
return patch(
"hindsight_api.engine.search.retrieval.retrieve_semantic_bm25_combined",
AsyncMock(return_value={"observation": (results, [])}),
)
def _patch_embed():
return patch(
"hindsight_api.engine.retain.embedding_utils.generate_embeddings_batch",
AsyncMock(return_value=[[0.1, 0.2, 0.3]]),
)
async def test_dedup_no_twin_above_threshold_returns_none() -> None:
kwargs, conn, llm = _ctx(threshold=0.97)
with _patch_embed(), _patch_probe([_obs("something loosely related", 0.81)]):
result = await _dedup_reconcile_create(**kwargs)
assert result is None
llm.call.assert_not_called() # below threshold → no LLM call
conn.execute.assert_not_called() # no merge
async def test_dedup_llm_keep_does_not_merge() -> None:
kwargs, conn, llm = _ctx()
llm.call.return_value = _DedupDecision(action="keep", reason="different language")
with _patch_embed(), _patch_probe([_obs("Uzbek content on YouTube is described as very rich.", 0.98)]):
result = await _dedup_reconcile_create(**kwargs)
assert result is None
llm.call.assert_awaited_once()
conn.execute.assert_not_called() # kept distinct → no merge
async def test_dedup_llm_merge_folds_into_twin() -> None:
kwargs, conn, llm = _ctx()
kwargs["create_source_ids"] = [uuid.uuid4(), uuid.uuid4()]
llm.call.return_value = _DedupDecision(action="merge", text="Uzbek content on YouTube is very rich.")
with _patch_embed(), _patch_probe([_obs("Uzbek content on YouTube is described as very rich.", 0.99)]):
result = await _dedup_reconcile_create(**kwargs)
assert result == _TWIN_ID # merged into the twin; caller skips the CREATE
conn.execute.assert_awaited_once()
args = conn.execute.await_args.args
assert args[1] == "Uzbek content on YouTube is very rich." # merged text persisted
assert args[2] == kwargs["create_source_ids"] # new source facts folded in
assert args[3] == uuid.UUID(_TWIN_ID) # onto the twin row
async def test_dedup_picks_highest_above_threshold_skips_below() -> None:
# Only the >=threshold candidate is considered; a 0.95 result is ignored at threshold 0.97.
kwargs, conn, llm = _ctx(threshold=0.97)
llm.call.return_value = _DedupDecision(action="keep")
with _patch_embed(), _patch_probe([_obs("near but distinct", 0.95), _obs("the real twin", 0.98)]):
await _dedup_reconcile_create(**kwargs)
# the twin passed to the LLM is the >=0.97 one, not the 0.95
sent = llm.call.await_args.kwargs["messages"][0]["content"]
assert "the real twin" in sent
assert "near but distinct" not in sent
# ── UPDATE-path dedup (_dedup_reconcile_update) ───────────────────────────────
#
# An UPDATE rewrites+re-embeds an observation, which can drift it into a near-twin of a
# DIFFERENT existing observation. These cover the fold-and-delete reconciliation (unlike
# CREATE, both rows already exist), the self-exclusion, and the keep/no-twin no-ops.
_UPDATED_ID = "44444444-4444-4444-8444-444444444444"
def _update_ctx(threshold: float = 0.97):
"""Return (kwargs, conn_mock, llm_mock) for a _dedup_reconcile_update call."""
conn = AsyncMock()
llm = types.SimpleNamespace(call=AsyncMock())
kwargs = dict(
conn=conn,
memory_engine=types.SimpleNamespace(embeddings=object()),
bank_id="bank1",
config=types.SimpleNamespace(consolidation_dedup_threshold=threshold),
dedup_llm_config=llm,
updated_id=_UPDATED_ID,
updated_text="Uzbek content on YouTube is very rich and growing.",
updated_emb_str="[0.1, 0.2, 0.3]", # already embedded by _execute_update_action
tags=["t1"],
)
return kwargs, conn, llm
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.")
with _patch_probe([_obs("Uzbek content on YouTube is described as very rich.", 0.98)]):
await _dedup_reconcile_update(**kwargs)
llm.call.assert_awaited_once()
# Two writes: fold-into-twin UPDATE, then DELETE of the updated row.
assert conn.execute.await_count == 2
fold_args = conn.execute.await_args_list[0].args
assert fold_args[1] == "Uzbek YouTube content is very rich and growing." # merged text on the twin
assert fold_args[2] == uuid.UUID(_TWIN_ID) # survivor = the twin
assert fold_args[3] == uuid.UUID(_UPDATED_ID) # folded-from = the updated row
delete_args = conn.execute.await_args_list[1].args
assert delete_args[1] == uuid.UUID(_UPDATED_ID) # the updated row is deleted
async def test_dedup_update_keep_does_not_merge() -> None:
kwargs, conn, llm = _update_ctx()
llm.call.return_value = _DedupDecision(action="keep", reason="different growth claim")
with _patch_probe([_obs("Uzbek content on YouTube is described as very rich.", 0.98)]):
await _dedup_reconcile_update(**kwargs)
llm.call.assert_awaited_once()
conn.execute.assert_not_called() # kept distinct → neither fold nor delete
async def test_dedup_update_excludes_self() -> None:
# The probe surfaces the updated observation itself at 1.0; it must be excluded so we don't
# "merge" a row into itself. With no other candidate, there is no twin → no LLM, no writes.
kwargs, conn, llm = _update_ctx()
with _patch_probe([_obs("its own current text", 1.0, oid=_UPDATED_ID)]):
await _dedup_reconcile_update(**kwargs)
llm.call.assert_not_called()
conn.execute.assert_not_called()
async def test_dedup_update_no_twin_above_threshold() -> None:
kwargs, conn, llm = _update_ctx(threshold=0.97)
with _patch_probe([_obs("loosely related", 0.8)]):
await _dedup_reconcile_update(**kwargs)
llm.call.assert_not_called()
conn.execute.assert_not_called()
# ── dedup activation gate (_dedup_active) ─────────────────────────────────────
#
# Enabled by default (threshold < 1.0), but skipped on Oracle because the merge path is
# Postgres-only — so the feature can ship on-by-default without breaking Oracle.
def _gate_cfg(threshold: float):
return types.SimpleNamespace(consolidation_dedup_threshold=threshold)
def _patch_backend(name: str):
return patch(
"hindsight_api.engine.consolidation.consolidator.get_config",
return_value=types.SimpleNamespace(database_backend=name),
)
def test_dedup_active_enabled_on_postgres() -> None:
with _patch_backend("postgresql"):
assert _dedup_active(_gate_cfg(0.97)) is True
def test_dedup_active_disabled_when_threshold_is_one() -> None:
with _patch_backend("postgresql"):
assert _dedup_active(_gate_cfg(1.0)) is False
def test_dedup_active_skipped_on_oracle() -> None:
# PG-only merge path → dedup is skipped on Oracle even with a sub-1.0 threshold.
with _patch_backend("oracle"):
assert _dedup_active(_gate_cfg(0.97)) is False
def test_dedup_active_none_config() -> None:
assert _dedup_active(None) is False
@@ -0,0 +1,41 @@
import uuid
import pytest
from hindsight_api.engine.consolidation import consolidator
class _ZeroLengthEmbeddings:
dimension = 384
def encode_documents(self, texts):
assert texts == ["Consolidated observation text."]
return [[]]
class _FakeMemoryEngine:
embeddings = _ZeroLengthEmbeddings()
class _FailingConn:
async def fetchrow(self, *args, **kwargs):
raise AssertionError("zero-length embedding should be rejected before database insert")
@pytest.mark.asyncio
async def test_create_observation_rejects_zero_length_embedding_before_insert(monkeypatch):
source_id = uuid.uuid4()
async def fake_filter_live_source_memories(conn, bank_id, source_memory_ids):
return source_memory_ids
monkeypatch.setattr(consolidator, "_filter_live_source_memories", fake_filter_live_source_memories)
with pytest.raises(RuntimeError, match="embedding 0 has dimension 0; expected 384"):
await consolidator._create_observation_directly(
conn=_FailingConn(),
memory_engine=_FakeMemoryEngine(),
bank_id="test-bank",
source_memory_ids=[source_id],
observation_text="Consolidated observation text.",
)
@@ -109,7 +109,9 @@ def _mock_llm_one_obs_per_fact():
def callback(messages, scope):
if scope != "consolidation":
return _ConsolidationBatchResponse()
prompt = messages[0]["content"] if messages else ""
# Facts live in the user message; the system message (stable, cached) carries
# example UUIDs in its OUTPUT samples — read user only.
prompt = "\n".join(m.get("content", "") for m in messages if m.get("role") == "user")
fact_ids = re.findall(r"\[([0-9a-f-]{36})\]", prompt)
creates = [
_CreateAction(text=f"Observation about fact {fid[:8]}", source_fact_ids=[fid])
@@ -194,9 +194,10 @@ class TestPostgreSQLDialect:
def test_build_semantic_arm(self, d):
arm = d.build_semantic_arm(
table="schema.memory_units", cols="id, text", fact_type="world",
embedding_param="$1", bank_id_param="$2", fetch_limit=100,
embedding_param="$1", bank_id_param="$2", fetch_limit=100, min_similarity=0.58,
)
assert "1 - (embedding <=> $1::vector)" in arm
assert ">= 0.58" in arm
assert "fact_type = 'world'" in arm
assert "LIMIT 100" in arm
assert "'semantic' AS source" in arm
@@ -232,16 +233,39 @@ class TestPostgreSQLDialect:
assert "to_bm25query" in arm
assert "tokenize" in arm
def test_build_bm25_arm_vchord_gates_zero_score_by_default(self, d):
"""VectorChord ranks every doc, so a score gate must filter non-matches.
The negated `<&>` score is BM25 (>= 0); the default 0 floor keeps only
rows with a genuine query-term match, mirroring native tsvector's `@@`.
"""
arm = d.build_bm25_arm(
table="t", cols="id", fact_type="world",
bank_id_param="$2", limit_param="$3", text_param="$4",
text_search_extension="vchord",
)
assert "-(search_vector <&> to_bm25query('idx_memory_units_text_search', tokenize($4, 'llmlingua2'))) > 0" in arm
def test_build_bm25_arm_vchord_honors_custom_min_score(self, d):
arm = d.build_bm25_arm(
table="t", cols="id", fact_type="world",
bank_id_param="$2", limit_param="$3", text_param="$4",
text_search_extension="vchord",
bm25_min_score=2.5,
)
assert "> 2.5" in arm
def test_build_bm25_arm_pgroonga(self, d):
arm = d.build_bm25_arm(
table="schema.memory_units", cols="id, text", fact_type="world",
bank_id_param="$2", limit_param="$3", text_param="$4",
text_search_extension="pgroonga",
)
# pgroonga uses the &@~ operator + pgroonga_score for ranking. The
# configured bm25_language is intentionally NOT used here — pgroonga's
# tokenizer is set at index creation, not query time.
assert "&@~ $4" in arm
# pgroonga uses the &@~ operator + pgroonga_score for ranking. Escape
# the query parameter so literal text containing pgroonga operators is
# not parsed as query syntax.
assert "&@~ pgroonga_query_escape($4)" in arm
assert "&@~ $4" not in arm
assert "pgroonga_score(tableoid, ctid)" in arm
assert "to_tsquery" not in arm
@@ -281,8 +305,8 @@ class TestPostgreSQLDialect:
assert result == "hello world"
def test_prepare_bm25_text_pgroonga(self, d):
# pgroonga accepts raw query text via &@~ and parses it with its own
# query syntax; we pass the original query through unchanged.
# Keep the user's text unchanged here; the SQL builder escapes the bind
# parameter at query time before invoking pgroonga's query parser.
result = d.prepare_bm25_text(["hello", "world"], "hello world", text_search_extension="pgroonga")
assert result == "hello world"
@@ -337,9 +361,10 @@ class TestOracleDialect:
def test_build_semantic_arm(self, d):
arm = d.build_semantic_arm(
table="memory_units", cols="id, text", fact_type="world",
embedding_param=":1", bank_id_param=":2", fetch_limit=100,
embedding_param=":1", bank_id_param=":2", fetch_limit=100, min_similarity=0.58,
)
assert "VECTOR_DISTANCE" in arm
assert ">= 0.58" in arm
assert "fact_type = 'world'" in arm
assert "FETCH FIRST 100 ROWS ONLY" in arm
assert "'semantic' AS source" in arm
@@ -0,0 +1,915 @@
"""Tests for document export/import between banks (LLM-free transfer).
These exercise the full export import round trip on a real (pg0) database with
the mock LLM fixture, and crucially assert that import does NOT invoke fact
extraction (the LLM) it replays the deterministic pipeline and re-embeds.
"""
import io
import json
import uuid
import zipfile
from datetime import datetime, timezone
import httpx
import pytest
import pytest_asyncio
from hindsight_api.api import create_app
from hindsight_api.engine.consolidation.consolidator import _create_observation_directly
from hindsight_api.engine.db_utils import acquire_with_retry
from hindsight_api.engine.schema import fq_table
from hindsight_api.engine.transfer import import_documents
from hindsight_api.engine.transfer.importer import parse_archive
from hindsight_api.engine.transfer.schema import SCHEMA_VERSION, TransferManifest
from hindsight_api.extensions import (
OperationValidatorExtension,
RecallContext,
ReflectContext,
RetainContext,
RetainResult,
ValidationResult,
)
from hindsight_api.webhooks.manager import WebhookManager
class _RetainResultCapture(OperationValidatorExtension):
"""Records each RetainResult the engine reports via on_retain_complete.
The pre-operation validators are required by the abstract base; they always
accept so they don't interfere with the operations under test.
"""
def __init__(self) -> None:
self.results: list[RetainResult] = []
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
return ValidationResult.accept()
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
return ValidationResult.accept()
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
return ValidationResult.accept()
async def on_retain_complete(self, result: RetainResult) -> None:
self.results.append(result)
@pytest_asyncio.fixture
async def api_client(memory):
"""Async HTTP client over the FastAPI app backed by the mock-LLM engine."""
app = create_app(memory, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
def _unique_bank(prefix: str) -> str:
return f"{prefix}_{datetime.now(timezone.utc).timestamp()}"
async def _retain(memory, bank_id, content, request_context, document_id):
await memory.retain_async(
bank_id=bank_id,
content=content,
context="Test context",
document_id=document_id,
request_context=request_context,
)
async def _import(memory, bank_id, archive, request_context, on_conflict="skip"):
"""Submit an import and return its result_metadata counts.
Import is async; the test fixture uses SyncTaskBackend so the operation runs
inline and is already completed when submit returns.
"""
submission = await memory.import_documents_async(bank_id, archive, request_context, on_conflict)
status = await memory.get_operation_status(
bank_id, submission["operation_id"], request_context=request_context
)
assert status["status"] == "completed", status
return status["result_metadata"]
def test_export_bank_covers_schema():
"""Every bank-scoped table must be classified by export_bank — logical, carried,
history, or explicitly skipped so a future migration can't silently drop one."""
from hindsight_api.admin.cli import BACKUP_TABLES
from hindsight_api.engine.transfer.export import (
_BANK_ROW_TABLES,
_CARRIED_HISTORY_TABLES,
_HISTORY_TABLES,
_REPLAYED_TABLES,
_SKIP_TABLES,
)
buckets = [
set(_REPLAYED_TABLES),
set(_BANK_ROW_TABLES),
set(_CARRIED_HISTORY_TABLES),
set(_HISTORY_TABLES),
set(_SKIP_TABLES),
]
classified = set().union(*buckets)
assert classified == set(BACKUP_TABLES), (
f"export-bank classification drifted from BACKUP_TABLES: "
f"missing={set(BACKUP_TABLES) - classified}, extra={classified - set(BACKUP_TABLES)}"
)
# No table may appear in two buckets.
assert sum(len(b) for b in buckets) == len(classified), "a table is classified in more than one bucket"
@pytest.mark.asyncio
async def test_export_bank_contents(memory, request_context):
"""export_bank produces a whole-bank archive: docs + bank config + webhooks,
no embeddings, with history gated behind include_history."""
from hindsight_api.engine.transfer import export_bank
bank = _unique_bank("export_bank")
webhook_id = uuid.uuid4()
try:
await _retain(memory, bank, "Carol lives in Paris.", request_context, "doc-1")
backend = await memory._get_backend()
async with acquire_with_retry(backend) as conn:
await conn.execute(
f"INSERT INTO {fq_table('webhooks')} "
f"(id, bank_id, url, secret, event_types, enabled, created_at, updated_at) "
f"VALUES ($1, $2, $3, NULL, $4, true, NOW(), NOW())",
webhook_id,
bank,
"https://example.com/hook",
["retain.completed"],
)
# Without history.
async with acquire_with_retry(backend) as conn:
archive = await export_bank(conn, bank, include_history=False)
with zipfile.ZipFile(io.BytesIO(archive)) as zf:
names = set(zf.namelist())
manifest = TransferManifest.model_validate_json(zf.read("manifest.json"))
bank_rows = json.loads(zf.read("banks.json"))
webhooks = json.loads(zf.read("webhooks.json"))
assert manifest.archive_type == "bank"
assert manifest.document_count == 1
assert manifest.webhook_count == 1
assert "mental_models.json" in names and "directives.json" in names
assert "mental_model_history.json" in names
assert any(d.endswith(".json") and d.startswith("documents/") for d in names)
# No history files unless requested.
assert not any(n.startswith("history/") for n in names)
# The bank row and webhook are carried.
assert [r["bank_id"] for r in bank_rows] == [bank]
assert webhooks[0]["bank_id"] == bank and webhooks[0]["url"] == "https://example.com/hook"
# No embeddings anywhere — the target instance regenerates them.
assert "embedding" not in archive.decode("utf-8", errors="ignore")
# With history.
async with acquire_with_retry(backend) as conn:
archive_h = await export_bank(conn, bank, include_history=True)
with zipfile.ZipFile(io.BytesIO(archive_h)) as zf:
names_h = set(zf.namelist())
manifest_h = TransferManifest.model_validate_json(zf.read("manifest.json"))
assert manifest_h.includes_history is True
assert "history/audit_log.json" in names_h and "history/llm_requests.json" in names_h
finally:
await memory.delete_bank(bank, request_context=request_context)
def _as_json(value):
"""Normalize a jsonb column value (str or already-decoded) to a Python object."""
return json.loads(value) if isinstance(value, str) else value
async def _bank_content_snapshot(memory, bank_id):
"""Capture the meaningful (non-embedding, non-volatile) content of a bank for
exact round-trip comparison across export import."""
backend = await memory._get_backend()
async with acquire_with_retry(backend) as conn:
bank = await conn.fetchrow(
f"SELECT name, disposition, mission, config FROM {fq_table('banks')} WHERE bank_id = $1", bank_id
)
docs = await conn.fetch(
f"SELECT id, original_text, tags FROM {fq_table('documents')} WHERE bank_id = $1", bank_id
)
facts = await conn.fetch(
f"SELECT text, fact_type, context FROM {fq_table('memory_units')} "
f"WHERE bank_id = $1 AND fact_type != 'observation'",
bank_id,
)
obs = await conn.fetch(
f"SELECT text, proof_count FROM {fq_table('memory_units')} WHERE bank_id = $1 AND fact_type = 'observation'",
bank_id,
)
ents = await conn.fetch(f"SELECT canonical_name FROM {fq_table('entities')} WHERE bank_id = $1", bank_id)
links = await conn.fetch(
f"SELECT link_type, count(*) AS c FROM {fq_table('memory_links')} WHERE bank_id = $1 GROUP BY link_type",
bank_id,
)
hooks = await conn.fetch(
f"SELECT url, event_types, enabled FROM {fq_table('webhooks')} WHERE bank_id = $1", bank_id
)
dirs = await conn.fetch(
f"SELECT name, content, priority, is_active FROM {fq_table('directives')} WHERE bank_id = $1", bank_id
)
mms = await conn.fetch(
f"SELECT subtype, name, description, tags FROM {fq_table('mental_models')} WHERE bank_id = $1", bank_id
)
null_emb = await conn.fetchval(
f"SELECT count(*) FROM {fq_table('memory_units')} "
f"WHERE bank_id = $1 AND fact_type != 'observation' AND embedding IS NULL",
bank_id,
)
return {
"bank": (bank["name"], _as_json(bank["disposition"]), bank["mission"], _as_json(bank["config"])),
"documents": sorted((d["id"], d["original_text"], tuple(sorted(d["tags"] or []))) for d in docs),
"facts": sorted((f["text"], f["fact_type"], f["context"]) for f in facts),
"observations": sorted((o["text"], o["proof_count"]) for o in obs),
"entities": sorted(e["canonical_name"].lower() for e in ents),
"links": {row["link_type"]: row["c"] for row in links},
"webhooks": sorted((h["url"], tuple(h["event_types"] or []), h["enabled"]) for h in hooks),
"directives": sorted((d["name"], d["content"], d["priority"], d["is_active"]) for d in dirs),
"mental_models": sorted(
(m["subtype"], m["name"], m["description"], tuple(sorted(m["tags"] or []))) for m in mms
),
"null_embeddings": null_emb,
}
@pytest.mark.asyncio
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
re-embedded. Uses export delete import so ids round-trip without collisions
(mirroring a fresh target instance)."""
bank = _unique_bank("bank_exact")
try:
await _retain(memory, bank, "Alice works at Google. Bob works at Microsoft.", request_context, "doc-1")
await _retain(memory, bank, "Carol lives in Paris.", request_context, "doc-2")
backend = await memory._get_backend()
async with acquire_with_retry(backend) as conn:
await conn.execute(
f"UPDATE {fq_table('banks')} SET name = $2, disposition = $3::jsonb, "
f"mission = $4, config = $5::jsonb WHERE bank_id = $1",
bank,
"My Bank",
json.dumps({"skepticism": 5, "literalism": 2, "empathy": 4}),
"Be terse and precise.",
json.dumps({"reflect_mission": "be terse"}),
)
await conn.execute(
f"INSERT INTO {fq_table('webhooks')} "
f"(id, bank_id, url, secret, event_types, enabled, created_at, updated_at) "
f"VALUES ($1, $2, $3, NULL, $4, true, NOW(), NOW())",
uuid.uuid4(),
bank,
"https://example.com/hook",
["retain.completed", "consolidation.completed"],
)
await conn.execute(
f"INSERT INTO {fq_table('directives')} "
f"(id, bank_id, name, content, priority, is_active, tags, created_at, updated_at) "
f"VALUES ($1, $2, $3, $4, $5, true, $6, NOW(), NOW())",
uuid.uuid4(),
bank,
"tone",
"Always be concise.",
7,
["style"],
)
await memory.create_mental_model(
bank,
name="Work model",
source_query="where do people work",
content="User tracks where people work.",
mental_model_id="mm-1",
tags=["people"],
request_context=request_context,
)
before = await _bank_content_snapshot(memory, bank)
# Sanity: the source genuinely has rich content in every section we carry.
assert before["facts"] and before["entities"] and before["links"]
assert before["webhooks"] and before["directives"] and before["mental_models"]
assert before["bank"][0] == "My Bank"
from hindsight_api.engine.transfer import export_bank
async with acquire_with_retry(backend) as conn:
archive = await export_bank(conn, bank)
# Delete then restore into the same id — exact round-trip, no PK collisions.
await memory.delete_bank(bank, request_context=request_context)
result = await memory.import_bank_async(archive, request_context)
assert result.bank_id == bank
assert result.webhooks_imported == 1
assert result.directives_imported == 1
assert result.mental_models_imported == 1
after = await _bank_content_snapshot(memory, bank)
# Semantic links are an ANN-approximate retrieval index regenerated from the
# (re-embedded) facts; their count depends on whether ANN runs incrementally
# per document (import) or as a final whole-bank pass (original retain), so
# compare them loosely. Everything else — source data and deterministic
# temporal links — must match exactly.
after_semantic = after["links"].pop("semantic", 0)
before["links"].pop("semantic", None)
assert after == before
assert after_semantic > 0, "semantic links should be regenerated on import"
# Facts were re-embedded on import (no NULL vectors).
assert after["null_embeddings"] == 0
finally:
await memory.delete_bank(bank, request_context=request_context)
@pytest.mark.asyncio
async def test_bank_roundtrip_carries_mental_model_history(memory, request_context):
"""Mental-model refresh history survives export/import. Mental models keep a
stable (id, bank_id), so the dedicated mental_model_history rows are carried
(the surrogate id is dropped on export; the target reassigns it)."""
bank = _unique_bank("bank_mm_hist")
try:
await memory.get_bank_profile(bank, request_context=request_context)
await memory.create_mental_model(
bank,
name="Work model",
source_query="where do people work",
content="v1",
mental_model_id="mm-1",
request_context=request_context,
)
await memory.update_mental_model(
bank, mental_model_id="mm-1", content="v2", request_context=request_context
)
await memory.update_mental_model(
bank, mental_model_id="mm-1", content="v3", request_context=request_context
)
# Two refreshes → two snapshots (previous content v1 then v2), newest-first.
before = await memory.get_mental_model_history(bank, "mm-1", request_context=request_context)
assert [h["previous_content"] for h in before] == ["v2", "v1"]
from hindsight_api.engine.transfer import export_bank
backend = await memory._get_backend()
async with acquire_with_retry(backend) as conn:
archive = await export_bank(conn, bank)
await memory.delete_bank(bank, request_context=request_context)
result = await memory.import_bank_async(archive, request_context)
assert result.mental_model_history_imported == 2
after = await memory.get_mental_model_history(bank, "mm-1", request_context=request_context)
assert [h["previous_content"] for h in after] == ["v2", "v1"]
finally:
await memory.delete_bank(bank, request_context=request_context)
@pytest.mark.asyncio
async def test_import_bank_rejects_documents_archive(memory, request_context):
"""A documents-only archive must be rejected by the bank importer."""
bank = _unique_bank("bank_reject")
try:
await _retain(memory, bank, "Alice works at Google.", request_context, "doc-1")
docs_archive = await memory.export_documents_async(bank, request_context)
with pytest.raises(ValueError, match="whole-bank archive"):
await memory.import_bank_async(docs_archive, request_context)
finally:
await memory.delete_bank(bank, request_context=request_context)
@pytest.mark.asyncio
async def test_import_bank_refuses_existing_bank(memory, request_context):
"""import-bank restores a whole bank, not a merge — it must refuse an existing target."""
from hindsight_api.engine.transfer import export_bank
bank = _unique_bank("bank_exists")
try:
await _retain(memory, bank, "Alice works at Google.", request_context, "doc-1")
backend = await memory._get_backend()
async with acquire_with_retry(backend) as conn:
archive = await export_bank(conn, bank)
# The source bank still exists — importing the archive back must refuse
# (restoring into the same id after delete is covered by the exact round-trip test).
with pytest.raises(ValueError, match="already exists"):
await memory.import_bank_async(archive, request_context)
finally:
await memory.delete_bank(bank, request_context=request_context)
@pytest.mark.asyncio
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")
dst = _unique_bank("transfer_dst")
try:
await _retain(
memory,
src,
"Alice works at Google. Bob works at Microsoft.",
request_context,
document_id="doc-1",
)
archive = await memory.export_documents_async(src, request_context)
assert isinstance(archive, bytes) and len(archive) > 0
parsed = parse_archive(archive)
assert parsed.manifest.source_bank_id == src
assert parsed.manifest.document_count == 1
assert parsed.manifest.fact_count > 0
# The archive must not carry embeddings or raw db ids (no "embedding" anywhere,
# now that the manifest no longer includes embedding model/dimension metadata).
assert "embedding" not in archive.decode("utf-8", errors="ignore")
exported_texts = {fact.text for doc in parsed.documents for fact in doc.facts}
assert exported_texts
# Importing must never call the LLM fact extractor — make it explode if it does.
def _boom(*args, **kwargs):
raise AssertionError("import must not invoke LLM fact extraction")
monkeypatch.setattr(
"hindsight_api.engine.retain.fact_extraction.extract_facts_from_contents",
_boom,
)
result = await _import(memory, dst, archive, request_context)
assert result["documents_imported"] == 1
assert result["documents_skipped"] == 0
assert result["facts_imported"] == parsed.manifest.fact_count
# Facts landed in the destination bank with matching text. Import triggers
# consolidation, which may synthesize observation units in the destination,
# so filter those out — the imported facts are world/experience only.
units = await memory.list_memory_units(dst, request_context=request_context)
imported_units = [item for item in units["items"] if item["fact_type"] != "observation"]
assert len(imported_units) == result["facts_imported"]
assert {item["text"] for item in imported_units} == exported_texts
# Entities were re-resolved in the destination bank.
entities = await memory.list_entities(dst, request_context=request_context)
entity_names = {e["canonical_name"].lower() for e in entities["items"]}
assert any("alice" in n for n in entity_names)
assert any("bob" in n for n in entity_names)
# Embeddings were regenerated locally (not null) in the destination.
backend = await memory._get_backend()
async with acquire_with_retry(backend) as conn:
null_embeddings = await conn.fetchval(
f"SELECT COUNT(*) FROM {fq_table('memory_units')} WHERE bank_id = $1 AND embedding IS NULL",
dst,
)
assert null_embeddings == 0
# And the imported memories are retrievable.
recall = await memory.recall_async(bank_id=dst, query="Where does Alice work?", request_context=request_context)
assert recall is not None
finally:
await memory.delete_bank(src, request_context=request_context)
await memory.delete_bank(dst, request_context=request_context)
async def _bank_snapshot(memory, bank_id):
"""Count everything persisted for a bank, for round-trip integrity comparison."""
backend = await memory._get_backend()
async with acquire_with_retry(backend) as conn:
docs = await conn.fetch(
f"SELECT id, COALESCE(length(original_text), 0) AS len FROM {fq_table('documents')} "
f"WHERE bank_id = $1",
bank_id,
)
chunks = await conn.fetch(
f"SELECT document_id, chunk_index, length(chunk_text) AS len FROM {fq_table('chunks')} "
f"WHERE bank_id = $1",
bank_id,
)
ftypes = await conn.fetch(
f"SELECT fact_type, count(*) AS c FROM {fq_table('memory_units')} WHERE bank_id = $1 GROUP BY fact_type",
bank_id,
)
links = await conn.fetch(
f"SELECT ml.link_type, count(*) AS c FROM {fq_table('memory_links')} ml "
f"JOIN {fq_table('memory_units')} m ON m.id = ml.from_unit_id "
f"WHERE m.bank_id = $1 GROUP BY ml.link_type",
bank_id,
)
unit_entities = await conn.fetchval(
f"SELECT count(*) FROM {fq_table('unit_entities')} ue "
f"JOIN {fq_table('memory_units')} m ON m.id = ue.unit_id WHERE m.bank_id = $1",
bank_id,
)
entities = await conn.fetchval(f"SELECT count(*) FROM {fq_table('entities')} WHERE bank_id = $1", bank_id)
facts_with_chunk = await conn.fetchval(
f"SELECT count(*) FROM {fq_table('memory_units')} WHERE bank_id = $1 AND chunk_id IS NOT NULL",
bank_id,
)
by_type = {r["fact_type"]: r["c"] for r in ftypes}
return {
"doc_count": len(docs),
"doc_lens": {r["id"]: r["len"] for r in docs},
"chunk_count": len(chunks),
# (document_id, chunk_index) -> chunk_text length: verifies attribution AND size.
"chunk_map": {(r["document_id"], r["chunk_index"]): r["len"] for r in chunks},
"world": by_type.get("world", 0),
"experience": by_type.get("experience", 0),
"observation": by_type.get("observation", 0),
"unit_entities": unit_entities,
"entities": entities,
"facts_with_chunk": facts_with_chunk,
"links_by_type": {r["link_type"]: r["c"] for r in links},
"links_total": sum(r["c"] for r in links),
}
@pytest.mark.asyncio
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")
dst = _unique_bank("transfer_integ_dst")
try:
# A multi-chunk document (content > chunk_size) plus a short one, so chunk
# numbering and fact→chunk attribution across chunks are exercised.
long_doc = " ".join(f"Person{i} works at Company{i} in City{i}." for i in range(220))
await _retain(memory, src, long_doc, request_context, "doc-long")
await _retain(memory, src, "Carol moved to Berlin in 2024 and joined Acme.", request_context, "doc-short")
before = await _bank_snapshot(memory, src)
# Sanity: the fixture actually produced multiple chunks + links + observations.
assert before["chunk_count"] >= 2
assert before["links_total"] > 0
assert before["observation"] > 0
archive = await memory.export_documents_async(src, request_context, include_observations=True)
await _import(memory, dst, archive, request_context)
after = await _bank_snapshot(memory, dst)
# Documents: same count and same original_text sizes (by id).
assert after["doc_count"] == before["doc_count"]
assert after["doc_lens"] == before["doc_lens"]
# Chunks: same count, and same (document, chunk_index) -> size map. This is
# the chunk-attribution guarantee.
assert after["chunk_count"] == before["chunk_count"]
assert after["chunk_map"] == before["chunk_map"]
# Facts: same world/experience/observation counts, same chunk linkage count.
assert after["world"] == before["world"]
assert after["experience"] == before["experience"]
assert after["observation"] == before["observation"]
assert after["facts_with_chunk"] == before["facts_with_chunk"]
# Entities + entity links re-resolved to the same counts.
assert after["entities"] == before["entities"]
assert after["unit_entities"] == before["unit_entities"]
# Links are regenerated against the target bank; for the same facts/embeddings
# the deterministic temporal + causal links must match exactly.
for link_type in ("temporal", "caused_by"):
assert after["links_by_type"].get(link_type, 0) == before["links_by_type"].get(link_type, 0), (
link_type,
before["links_by_type"],
after["links_by_type"],
)
# And links overall must be present (semantic counts can vary slightly with
# ANN ordering, so we don't assert exact equality on the total).
assert after["links_total"] > 0
finally:
await memory.delete_bank(src, request_context=request_context)
await memory.delete_bank(dst, request_context=request_context)
@pytest.mark.asyncio
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")
dst = _unique_bank("transfer_obs_dst")
try:
await _retain(memory, src, "Alice works at Google. Bob works at Microsoft.", request_context, "doc-1")
# Sources must be world/experience facts (not auto-consolidation observations).
units = await memory.list_memory_units(src, fact_type="world", request_context=request_context)
source_ids = [uuid.UUID(str(i["id"])) for i in units["items"][:2]]
assert len(source_ids) == 2
# Create a real observation over those source facts.
backend = await memory._get_backend()
async with acquire_with_retry(backend) as conn:
async with conn.transaction():
await _create_observation_directly(
conn, memory, src, source_ids, "Alice and Bob are colleagues."
)
# Export WITHOUT observations -> none in the archive (the bank may also
# contain auto-consolidation observations; the flag is what gates them).
plain = parse_archive(await memory.export_documents_async(src, request_context))
assert plain.manifest.observation_count == 0
assert plain.observations == []
# Export WITH observations. (The mock LLM's auto-consolidation may have
# produced extra observations too, so assert on our specific one.)
archive = await memory.export_documents_async(src, request_context, include_observations=True)
parsed = parse_archive(archive)
assert parsed.manifest.observation_count == len(parsed.observations) >= 1
mine = next((o for o in parsed.observations if o.text == "Alice and Bob are colleagues."), None)
assert mine is not None
assert len(mine.sources) == 2 # both sources resolved within the export
assert "embedding" not in archive.decode("utf-8", errors="ignore")
# Import into a fresh bank. Every exported observation's sources are in
# the single exported document, so all import and none are skipped.
result = await _import(memory, dst, archive, request_context)
assert result["observations_imported"] == parsed.manifest.observation_count
assert result["observations_skipped"] == 0
# Our observation landed with source_memory_ids pointing at dst's facts,
# and those source facts are marked consolidated.
async with acquire_with_retry(backend) as conn:
obs_row = await conn.fetchrow(
f"SELECT source_memory_ids FROM {fq_table('memory_units')} "
f"WHERE bank_id = $1 AND fact_type = 'observation' AND text = $2",
dst,
"Alice and Bob are colleagues.",
)
assert obs_row is not None
dst_sources = list(obs_row["source_memory_ids"] or [])
assert len(dst_sources) == 2
consolidated = await conn.fetchval(
f"SELECT COUNT(*) FROM {fq_table('memory_units')} "
f"WHERE bank_id = $1 AND id = ANY($2) AND consolidated_at IS NOT NULL",
dst,
dst_sources,
)
assert consolidated == 2
finally:
await memory.delete_bank(src, request_context=request_context)
await memory.delete_bank(dst, request_context=request_context)
@pytest.mark.asyncio
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."""
src = _unique_bank("transfer_consol_src")
dst = _unique_bank("transfer_consol_dst")
try:
await _retain(memory, src, "Alice works at Google. Bob works at Microsoft.", request_context, "doc-1")
# Export WITHOUT observations: the archive carries only world/experience facts.
archive = await memory.export_documents_async(src, request_context)
assert parse_archive(archive).observations == []
# Import into a fresh bank. The post-import consolidation trigger runs
# inline (SyncTaskBackend) and the mock LLM produces observations.
await _import(memory, dst, archive, request_context)
obs = await memory.list_memory_units(dst, fact_type="observation", request_context=request_context)
assert obs["total"] > 0, "import should have triggered consolidation to generate observations"
finally:
await memory.delete_bank(src, request_context=request_context)
await memory.delete_bank(dst, request_context=request_context)
@pytest.mark.asyncio
async def test_import_fires_retain_complete_hook(memory, request_context):
"""Import fires the post-retain extension hook once per imported document,
mirroring retain with zero LLM tokens (import runs no extraction)."""
src = _unique_bank("transfer_hook_src")
dst = _unique_bank("transfer_hook_dst")
await _retain(memory, src, "Alice works at Google.", request_context, "doc-1")
await _retain(memory, src, "Bob works at Microsoft.", request_context, "doc-2")
archive = await memory.export_documents_async(src, request_context)
capture = _RetainResultCapture()
original_validator = memory._operation_validator
memory._operation_validator = capture
try:
result = await _import(memory, dst, archive, request_context)
assert result["documents_imported"] == 2
# One hook call per imported document.
assert len(capture.results) == 2
by_doc = {r.document_id: r for r in capture.results}
assert set(by_doc) == {"doc-1", "doc-2"}
for res in capture.results:
assert res.bank_id == dst
assert res.success is True
# Import runs no LLM extraction: token counts are zero and
# processed_content_tokens is 0 ("nothing went through extraction").
assert res.llm_input_tokens == 0
assert res.llm_output_tokens == 0
assert res.llm_total_tokens == 0
assert res.processed_content_tokens == 0
# unit_ids are reported per content item, with the created facts.
assert res.unit_ids and res.unit_ids[0]
finally:
memory._operation_validator = original_validator
await memory.delete_bank(src, request_context=request_context)
await memory.delete_bank(dst, request_context=request_context)
@pytest.mark.asyncio
async def test_import_queues_retain_webhook(memory, request_context):
"""Import queues a retain.completed webhook delivery per document, like retain."""
src = _unique_bank("transfer_wh_src")
dst = _unique_bank("transfer_wh_dst")
webhook_id = uuid.uuid4()
await _retain(memory, src, "Carol lives in Paris.", request_context, "doc-wh")
archive = await memory.export_documents_async(src, request_context)
# The destination bank is created lazily by import; create it now so the
# webhook row's FK to banks is satisfied, then subscribe it to retain.completed.
backend = await memory._get_backend()
async with acquire_with_retry(backend) as conn:
await conn.execute(
f"INSERT INTO {fq_table('banks')} (bank_id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING",
dst,
dst,
)
await conn.execute(
f"INSERT INTO {fq_table('webhooks')} "
f"(id, bank_id, url, secret, event_types, enabled, created_at, updated_at) "
f"VALUES ($1, $2, $3, NULL, $4, true, NOW(), NOW())",
webhook_id,
dst,
"https://example.com/retain-hook",
["retain.completed"],
)
original_manager = memory._webhook_manager
memory._webhook_manager = WebhookManager(backend=memory._backend, global_webhooks=[])
try:
await _import(memory, dst, archive, request_context)
async with acquire_with_retry(backend) as conn:
rows = await conn.fetch(
f"SELECT task_payload FROM {fq_table('async_operations')} "
f"WHERE operation_type = 'webhook_delivery' AND bank_id = $1 "
f"AND task_payload->>'event_type' = 'retain.completed'",
dst,
)
assert len(rows) == 1, "import should queue one retain.completed delivery for the imported document"
payload = rows[0]["task_payload"]
if isinstance(payload, str):
payload = json.loads(payload)
inner = json.loads(payload["payload"])
assert inner.get("data", {}).get("document_id") == "doc-wh"
finally:
memory._webhook_manager = original_manager
await memory.delete_bank(src, request_context=request_context)
await memory.delete_bank(dst, request_context=request_context)
@pytest.mark.asyncio
async def test_include_observations_requires_whole_bank_export(memory, request_context):
"""include_observations is only valid for a whole-bank export, not a subset."""
src = _unique_bank("transfer_obs_subset")
try:
await _retain(memory, src, "Alice works at Google.", request_context, "doc-1")
# Subset export (document_ids set) + observations must be rejected.
with pytest.raises(ValueError, match="whole bank"):
await memory.export_documents_async(
src, request_context, ["doc-1"], include_observations=True
)
# Whole-bank export with observations is fine; subset without observations is fine.
await memory.export_documents_async(src, request_context, include_observations=True)
await memory.export_documents_async(src, request_context, ["doc-1"])
finally:
await memory.delete_bank(src, request_context=request_context)
@pytest.mark.asyncio
async def test_import_on_conflict_modes(memory, request_context):
"""skip leaves the document untouched; replace re-imports; new-id duplicates under a fresh id."""
src = _unique_bank("transfer_conf")
try:
await _retain(memory, src, "Carol lives in Paris.", request_context, document_id="doc-x")
archive = await memory.export_documents_async(src, request_context)
# Re-importing into the SAME bank with skip is a no-op.
skipped = await _import(memory, src, archive, request_context, on_conflict="skip")
assert skipped["documents_imported"] == 0
assert skipped["documents_skipped"] == 1
assert skipped["skipped_document_ids"] == ["doc-x"]
docs_after_skip = await memory.list_documents(src, request_context=request_context)
assert docs_after_skip["total"] == 1
# replace re-imports under the same id.
replaced = await _import(memory, src, archive, request_context, on_conflict="replace")
assert replaced["documents_imported"] == 1
assert replaced["documents_skipped"] == 0
docs_after_replace = await memory.list_documents(src, request_context=request_context)
assert docs_after_replace["total"] == 1
# new-id imports a copy under a freshly generated id.
remapped = await _import(memory, src, archive, request_context, on_conflict="new-id")
assert remapped["documents_imported"] == 1
assert "doc-x" in remapped["remapped_document_ids"]
docs_after_newid = await memory.list_documents(src, request_context=request_context)
assert docs_after_newid["total"] == 2
finally:
await memory.delete_bank(src, request_context=request_context)
@pytest.mark.asyncio
async def test_http_export_import_endpoints(api_client, memory, request_context):
"""Round trip through the HTTP export (GET) and import (POST multipart) endpoints."""
src = _unique_bank("transfer_http_src")
dst = _unique_bank("transfer_http_dst")
try:
await _retain(memory, src, "Dana lives in Berlin.", request_context, document_id="doc-http")
export = await api_client.get(f"/v1/default/banks/{src}/document-transfer")
assert export.status_code == 200
assert export.headers["content-type"] == "application/zip"
archive = export.content
assert len(archive) > 0
# include_observations + a document_id subset is a 400.
bad = await api_client.get(
f"/v1/default/banks/{src}/document-transfer",
params={"document_id": "meeting-notes", "include_observations": "true"},
)
assert bad.status_code == 400
# Import is async: returns 202 + operation_id (runs inline under the
# SyncTaskBackend test fixture, so it's completed by the time we poll).
imported = await api_client.post(
f"/v1/default/banks/{dst}/document-transfer",
files={"file": ("transfer.zip", archive, "application/zip")},
params={"on_conflict": "skip"},
)
assert imported.status_code == 202
operation_id = imported.json()["operation_id"]
status = await api_client.get(f"/v1/default/banks/{dst}/operations/{operation_id}")
assert status.status_code == 200
op = status.json()
assert op["status"] == "completed"
assert op["result_metadata"]["documents_imported"] == 1
assert op["result_metadata"]["facts_imported"] >= 1
# Exporting a bank that does not exist is a 404.
missing = await api_client.get("/v1/default/banks/does-not-exist-bank/document-transfer")
assert missing.status_code == 404
finally:
await memory.delete_bank(src, request_context=request_context)
await memory.delete_bank(dst, request_context=request_context)
@pytest.mark.asyncio
async def test_endpoints_disabled_by_config(api_client, monkeypatch):
"""When the feature flags are off, the endpoints return 404 and /version reports disabled."""
from hindsight_api.config import clear_config_cache
# The static config is a cached singleton; override via env + cache reset.
monkeypatch.setenv("HINDSIGHT_API_ENABLE_DOCUMENT_EXPORT_API", "false")
monkeypatch.setenv("HINDSIGHT_API_ENABLE_DOCUMENT_IMPORT_API", "false")
clear_config_cache()
try:
export = await api_client.get("/v1/default/banks/any-bank/document-transfer")
assert export.status_code == 404
assert "disabled" in export.json()["detail"].lower()
imported = await api_client.post(
"/v1/default/banks/any-bank/document-transfer",
files={"file": ("x.zip", b"not-a-zip", "application/zip")},
)
assert imported.status_code == 404
assert "disabled" in imported.json()["detail"].lower()
version = await api_client.get("/version")
features = version.json()["features"]
assert features["document_export_api"] is False
assert features["document_import_api"] is False
finally:
# Restore the cache so the reverted env is picked up by later tests.
clear_config_cache()
@pytest.mark.asyncio
async def test_import_rejects_unsupported_schema_version(memory, request_context):
"""An archive with an unknown schema version is rejected before any writes."""
manifest = TransferManifest(schema_version=SCHEMA_VERSION + 999, source_bank_id="whatever")
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
zf.writestr("manifest.json", manifest.model_dump_json())
with pytest.raises(ValueError, match="schema version"):
await memory.import_documents_async("any-bank", buffer.getvalue(), request_context)
@pytest.mark.asyncio
async def test_import_rejects_invalid_on_conflict(memory, request_context):
"""An unknown on_conflict mode is rejected with a ValueError."""
manifest = TransferManifest(source_bank_id="whatever")
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
zf.writestr("manifest.json", manifest.model_dump_json())
with pytest.raises(ValueError, match="on_conflict"):
await import_documents(
backend=await memory._get_backend(),
embeddings_model=memory.embeddings,
entity_resolver=memory.entity_resolver,
config=None,
format_date_fn=memory._format_readable_date,
bank_id="any-bank",
archive_bytes=buffer.getvalue(),
on_conflict="bogus",
)
@@ -635,6 +635,30 @@ class TestMemoryEngineTenantAuth:
request_context=None,
)
@pytest.mark.asyncio
async def test_list_audit_logs_fails_with_invalid_api_key(self, memory_with_tenant):
"""Audit log listing goes through tenant auth like other ops."""
memory = memory_with_tenant
with pytest.raises(AuthenticationError) as exc_info:
await memory.list_audit_logs(
"test-bank",
request_context=RequestContext(api_key="wrong-key"),
)
assert "Invalid API key" in str(exc_info.value)
@pytest.mark.asyncio
async def test_audit_log_stats_fails_with_invalid_api_key(self, memory_with_tenant):
"""Audit log stats goes through tenant auth like other ops."""
memory = memory_with_tenant
with pytest.raises(AuthenticationError):
await memory.audit_log_stats(
"test-bank",
request_context=RequestContext(api_key="wrong-key"),
)
@pytest.mark.asyncio
async def test_no_tenant_request_needed_without_extension(self, memory):
"""Operations work with empty RequestContext when no tenant extension configured."""
@@ -0,0 +1,42 @@
"""Tests for per-source candidate capping before RRF fusion."""
from hindsight_api.engine.search.fusion import cap_per_source
from hindsight_api.engine.search.types import RetrievalResult
def _results(n: int) -> list[RetrievalResult]:
return [RetrievalResult(id=str(i), text=f"r{i}", fact_type="world") for i in range(n)]
def test_cap_truncates_to_top_n():
results = _results(10)
capped = cap_per_source(results, 3)
assert [r.id for r in capped] == ["0", "1", "2"]
def test_cap_preserves_order():
"""Capping must keep the caller's best-first ordering (it only slices)."""
results = _results(5)
capped = cap_per_source(results, 2)
assert capped == results[:2]
def test_cap_zero_disables():
results = _results(5)
# 0 means "unlimited" — return the list untouched (same object, no copy).
assert cap_per_source(results, 0) is results
def test_cap_negative_disables():
results = _results(5)
assert cap_per_source(results, -1) is results
def test_cap_at_or_above_length_is_noop():
results = _results(4)
assert cap_per_source(results, 4) is results
assert cap_per_source(results, 10) is results
def test_cap_empty_list():
assert cap_per_source([], 5) == []
@@ -0,0 +1,469 @@
"""Unit tests for GeminiCacheManager.
The SDK's caches.create call is replaced with a fake throughout — no
network, no real Gemini calls. We assert:
* Identical prefixes return identical fingerprints (cache hits).
* Different prefixes return different fingerprints.
* The first get_or_create for a fingerprint creates; the second within
the TTL window reuses without calling the SDK again.
* "minimum token count" errors from Gemini surface as ``None`` (soft
fallback), not exceptions.
* Other SDK errors also surface as ``None`` so callers don't crash on
transient creation failures.
* The TTL refresh boundary recreates after the safety margin elapses.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from hindsight_api.engine.providers.gemini_cache import GeminiCacheManager
def _make_client(create_side_effect=None):
"""Build a fake Gemini client whose ``aio.caches.create`` returns
a SimpleNamespace with ``.name`` (or raises the given exception)."""
create_mock = AsyncMock()
if isinstance(create_side_effect, Exception):
create_mock.side_effect = create_side_effect
elif callable(create_side_effect):
create_mock.side_effect = create_side_effect
else:
create_mock.return_value = SimpleNamespace(
name="cachedContents/test-cache-name-001"
)
client = MagicMock()
client.aio = MagicMock()
client.aio.caches = MagicMock()
client.aio.caches.create = create_mock
return client, create_mock
# ---- Fingerprint properties ----------------------------------------------
def test_fingerprint_is_stable():
fp1 = GeminiCacheManager.fingerprint(
model="gemini-3.1-flash-lite",
system_instruction="Extract facts.",
response_schema=None,
)
fp2 = GeminiCacheManager.fingerprint(
model="gemini-3.1-flash-lite",
system_instruction="Extract facts.",
response_schema=None,
)
assert fp1 == fp2
def test_fingerprint_changes_with_model():
fp1 = GeminiCacheManager.fingerprint("gemini-3.1-flash-lite", "X", None)
fp2 = GeminiCacheManager.fingerprint("gemini-3.1-flash", "X", None)
assert fp1 != fp2
def test_fingerprint_changes_with_system_instruction():
fp1 = GeminiCacheManager.fingerprint("m", "Extract facts.", None)
fp2 = GeminiCacheManager.fingerprint("m", "Extract entities.", None)
assert fp1 != fp2
def test_fingerprint_handles_pydantic_schema():
"""Two equivalent Pydantic schemas should fingerprint identically;
a different shape should not."""
from pydantic import BaseModel
class A(BaseModel):
x: int
y: str
class A_dup(BaseModel):
x: int
y: str
class B(BaseModel):
x: int
y: int # different type
fp_a = GeminiCacheManager.fingerprint("m", "p", A)
fp_dup = GeminiCacheManager.fingerprint("m", "p", A_dup)
fp_b = GeminiCacheManager.fingerprint("m", "p", B)
# A and A_dup have the same JSON schema, even though they're distinct classes.
assert fp_a == fp_dup
assert fp_a != fp_b
# ---- get_or_create lifecycle ---------------------------------------------
@pytest.mark.asyncio
async def test_first_call_creates_subsequent_reuses():
client, create_mock = _make_client()
mgr = GeminiCacheManager(client)
name1 = await mgr.get_or_create(
model="gemini-3.1-flash-lite",
system_instruction="Extract facts.",
response_schema=None,
)
name2 = await mgr.get_or_create(
model="gemini-3.1-flash-lite",
system_instruction="Extract facts.",
response_schema=None,
)
assert name1 == "cachedContents/test-cache-name-001"
assert name2 == name1
# Only ONE underlying create call — second was served from in-memory cache.
assert create_mock.call_count == 1
@pytest.mark.asyncio
async def test_different_prefixes_create_separately():
client, create_mock = _make_client(
create_side_effect=lambda *a, **kw: SimpleNamespace(
name=f"cachedContents/created-{create_mock.call_count}"
)
)
mgr = GeminiCacheManager(client)
name_a = await mgr.get_or_create(
model="m", system_instruction="A", response_schema=None
)
name_b = await mgr.get_or_create(
model="m", system_instruction="B", response_schema=None
)
assert name_a != name_b
assert create_mock.call_count == 2
# ---- Failure / fallback handling -----------------------------------------
@pytest.mark.asyncio
async def test_minimum_token_count_error_returns_none():
"""When the prefix is too short, Gemini rejects with a 'minimum
token count' style message. Manager must surface this as None so
the caller transparently falls back to a non-cached call."""
err = Exception("Cached content must have at least 1024 input tokens (minimum)")
client, _ = _make_client(create_side_effect=err)
mgr = GeminiCacheManager(client)
result = await mgr.get_or_create(
model="m", system_instruction="tiny", response_schema=None
)
assert result is None
@pytest.mark.asyncio
async def test_other_sdk_errors_also_return_none():
"""Transient errors (rate limits, 5xx, etc.) should fail soft so
a single bad create doesn't crash every retain call."""
err = RuntimeError("transient backend error 503")
client, _ = _make_client(create_side_effect=err)
mgr = GeminiCacheManager(client)
result = await mgr.get_or_create(
model="m", system_instruction="ok-sized prefix", response_schema=None
)
assert result is None
@pytest.mark.asyncio
async def test_failed_create_does_not_poison_cache():
"""If create fails on first attempt, a retry should call create
again instead of returning a stale/None entry."""
call_log = []
async def maybe_fail(*args, **kwargs):
call_log.append(1)
if len(call_log) == 1:
raise RuntimeError("first call fails")
return SimpleNamespace(name="cachedContents/recovered")
client = MagicMock()
client.aio = MagicMock()
client.aio.caches = MagicMock()
client.aio.caches.create = maybe_fail
mgr = GeminiCacheManager(client)
first = await mgr.get_or_create(
model="m", system_instruction="prefix", response_schema=None
)
second = await mgr.get_or_create(
model="m", system_instruction="prefix", response_schema=None
)
assert first is None
assert second == "cachedContents/recovered"
assert len(call_log) == 2
# ---- TTL behaviour --------------------------------------------------------
@pytest.mark.asyncio
async def test_refreshes_after_ttl_margin(monkeypatch):
"""An entry created at t=0 with ttl=10 and margin=2 should be
treated as stale at t>=8 and trigger a recreate."""
client, create_mock = _make_client(
create_side_effect=lambda *a, **kw: SimpleNamespace(
name=f"cachedContents/v{create_mock.call_count}"
)
)
mgr = GeminiCacheManager(client, ttl_seconds=10, refresh_margin_seconds=2)
fake_now = {"t": 1000.0}
monkeypatch.setattr(
"hindsight_api.engine.providers.gemini_cache.time.monotonic",
lambda: fake_now["t"],
)
first = await mgr.get_or_create(
model="m", system_instruction="p", response_schema=None
)
assert first == "cachedContents/v1"
# Advance to just before the refresh boundary — should reuse.
fake_now["t"] = 1000.0 + 7.0
again = await mgr.get_or_create(
model="m", system_instruction="p", response_schema=None
)
assert again == "cachedContents/v1"
assert create_mock.call_count == 1
# Advance past the refresh boundary — should recreate.
fake_now["t"] = 1000.0 + 9.0
refreshed = await mgr.get_or_create(
model="m", system_instruction="p", response_schema=None
)
assert refreshed == "cachedContents/v2"
assert create_mock.call_count == 2
# ---- Integration: feature flag + GeminiLLM accessor ----------------------
@pytest.mark.asyncio
async def test_gemini_llm_returns_none_when_cache_disabled():
"""A directly-constructed GeminiLLM (no prompt_cache_enabled kwarg) does not
cache: ``get_or_create_cached_prefix`` returns None without ever building a
cache manager. The server-level default-on flows in via the kwarg (resolved
from config in LLMProvider), not via this constructor default."""
from hindsight_api.engine.providers.gemini_llm import GeminiLLM
llm = GeminiLLM(
provider="gemini",
api_key="not-real-key",
base_url="",
model="gemini-test",
)
# Constructor default is off; even with a stable prefix the cache stays disabled.
result = await llm.get_or_create_cached_prefix(
system_instruction="A reasonably long system prompt " * 50,
response_schema=None,
)
assert result is None
assert llm._cache_manager is None # never built
@pytest.mark.asyncio
async def test_gemini_llm_uses_cache_when_enabled(monkeypatch):
"""When the flag is on, the manager is constructed lazily and its
get_or_create is delegated to. We don't hit the real SDK; we replace
the client's caches.create with a fake."""
from types import SimpleNamespace
from unittest.mock import AsyncMock
from hindsight_api.engine.providers.gemini_llm import GeminiLLM
llm = GeminiLLM(
provider="gemini",
api_key="not-real-key",
base_url="",
model="gemini-test",
prompt_cache_enabled=True,
)
# Replace the SDK-shaped client with a fake whose caches.create returns
# a predictable name. The lazy import inside get_or_create_cached_prefix
# picks up the patched module-level GeminiCacheManager naturally.
fake_create = AsyncMock(
return_value=SimpleNamespace(name="cachedContents/from-llm-test")
)
llm._client = MagicMock()
llm._client.aio = MagicMock()
llm._client.aio.caches = MagicMock()
llm._client.aio.caches.create = fake_create
name = await llm.get_or_create_cached_prefix(
system_instruction="A long enough system prompt for caching",
response_schema=None,
)
assert name == "cachedContents/from-llm-test"
# The manager was lazy-built on first use.
assert llm._cache_manager is not None
# Second call within TTL → no new SDK call.
again = await llm.get_or_create_cached_prefix(
system_instruction="A long enough system prompt for caching",
response_schema=None,
)
assert again == name
assert fake_create.call_count == 1
@pytest.mark.asyncio
async def test_call_falls_back_to_uncached_when_cache_400s():
"""A stale/invalid CachedContent makes the generate call 400. The provider
must drop the cache, invalidate the entry, and retry the SAME call inline
(prefix re-sent) so caching never breaks a request."""
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock
from google.genai import errors as genai_errors
from hindsight_api.engine.providers.gemini_cache import GeminiCacheManager, _CacheEntry
from hindsight_api.engine.providers.gemini_llm import GeminiLLM
llm = GeminiLLM(provider="gemini", api_key="not-real-key", base_url="", model="gemini-test", prompt_cache_enabled=True)
# Seed a cache manager entry that maps to the (now invalid) cache name.
mgr = GeminiCacheManager(client=MagicMock())
mgr._entries["fp"] = _CacheEntry(name="cachedContents/stale", created_at=time.monotonic(), ttl_seconds=3300)
llm._cache_manager = mgr
captured = []
def _gen(*, model, contents, config):
captured.append(config)
if len(captured) == 1:
# First (cached) attempt — Gemini rejects the dead cache.
raise genai_errors.ClientError(
400, {"error": {"code": 400, "status": "INVALID_ARGUMENT", "message": "CachedContent not found"}}
)
# Retry without the cache succeeds.
return SimpleNamespace(
text="extracted",
usage_metadata=SimpleNamespace(
prompt_token_count=10, candidates_token_count=2, cached_content_token_count=0, thoughts_token_count=0
),
candidates=[SimpleNamespace(finish_reason="STOP")],
)
llm._client = MagicMock()
llm._client.aio = MagicMock()
llm._client.aio.models = MagicMock()
llm._client.aio.models.generate_content = AsyncMock(side_effect=_gen)
result = await llm.call(
messages=[{"role": "system", "content": "SYSTEM PREFIX"}, {"role": "user", "content": "doc"}],
cached_prefix="cachedContents/stale",
max_retries=2,
temperature=0.1,
)
# The request succeeded via the uncached retry.
assert result == "extracted"
assert len(captured) == 2
# First attempt referenced the cache; the retry inlined the prefix instead.
assert captured[0].cached_content == "cachedContents/stale"
assert captured[1].cached_content is None
assert captured[1].system_instruction == "SYSTEM PREFIX"
# The dead entry was invalidated so the next operation recreates it.
assert mgr._entries == {}
@pytest.mark.asyncio
async def test_create_cache_times_out_and_falls_back():
"""The create runs under the manager lock, so a hung caches.create would block
every concurrent caller (e.g. all chunks of a retain batch). It must time out
and return None so callers proceed uncached instead of stalling."""
import asyncio
from types import SimpleNamespace
from hindsight_api.engine.providers.gemini_cache import GeminiCacheManager
async def _hang(*args, **kwargs):
await asyncio.sleep(5)
return SimpleNamespace(name="never")
client = MagicMock()
client.aio = MagicMock()
client.aio.caches = MagicMock()
client.aio.caches.create = _hang
mgr = GeminiCacheManager(client, create_timeout_seconds=0.05)
result = await mgr.get_or_create(model="m", system_instruction="long enough prefix " * 20)
assert result is None
assert mgr._entries == {}
# ---- Tools: cache key + create wiring -----------------------------------
def test_fingerprint_changes_with_tools():
"""Two prefixes that differ ONLY in tools must hash differently —
otherwise a loop that adds a tool would silently reuse a stale
cache that doesn't know about it."""
tools_a = [
{"type": "function", "function": {"name": "search", "description": "search", "parameters": {}}}
]
tools_b = [
{"type": "function", "function": {"name": "search", "description": "search", "parameters": {}}},
{"type": "function", "function": {"name": "fetch", "description": "fetch", "parameters": {}}},
]
fp_a = GeminiCacheManager.fingerprint("m", "sys", None, tools=tools_a)
fp_b = GeminiCacheManager.fingerprint("m", "sys", None, tools=tools_b)
assert fp_a != fp_b
def test_fingerprint_stable_under_dict_reordering():
"""The tools list contains dicts; iteration order of dict keys
must not affect the fingerprint (otherwise upstream re-serialisation
would produce phantom cache misses)."""
tools_1 = [{"type": "function", "function": {"description": "d", "name": "n", "parameters": {"a": 1, "b": 2}}}]
tools_2 = [{"function": {"parameters": {"b": 2, "a": 1}, "name": "n", "description": "d"}, "type": "function"}]
fp_1 = GeminiCacheManager.fingerprint("m", "sys", None, tools=tools_1)
fp_2 = GeminiCacheManager.fingerprint("m", "sys", None, tools=tools_2)
assert fp_1 == fp_2
@pytest.mark.asyncio
async def test_get_or_create_passes_tools_to_create():
"""When tools are provided, the underlying caches.create call
must include them so the cached prefix actually contains the tool
definitions."""
captured = {}
async def fake_create(*, model, config):
captured["model"] = model
captured["config_dict"] = config.__dict__ if hasattr(config, "__dict__") else dict(config)
return SimpleNamespace(name="cachedContents/with-tools")
client = MagicMock()
client.aio = MagicMock()
client.aio.caches = MagicMock()
client.aio.caches.create = fake_create
mgr = GeminiCacheManager(client)
tools = [
{"type": "function", "function": {"name": "search", "description": "do a search", "parameters": {"type": "object"}}}
]
name = await mgr.get_or_create(
model="gemini-3.1-flash-lite",
system_instruction="You are a helpful tool-using assistant.",
tools=tools,
)
assert name == "cachedContents/with-tools"
# The Gemini SDK's CreateCachedContentConfig accepted a `tools` list.
cfg = captured["config_dict"]
assert "tools" in cfg, f"tools should be in cache config; got keys: {list(cfg.keys())}"
assert cfg["tools"], "tools list should be non-empty"
@@ -0,0 +1,294 @@
"""Measure the cached/input token ratio per operation against real Gemini.
Each operation re-sends a large constant prefix and a small variable payload:
- ``retain_extract_facts`` fact-extraction system prompt + schema
- ``reflect_tool_call`` agent system prompt + tool definitions, reused across
every iteration of the tool loop
- ``consolidation`` the stable mission/rules/decision/output system prefix,
reused across every consolidation batch
We run real Gemini, route every call through the LLM-request tracer (#1922), and
read back recorded ``cached_tokens`` vs ``input_tokens`` per scope.
Two modes:
- Default (implicit only): Gemini's automatic caching — empirically ~0% for this
low-QPS access pattern. Structural assertions only; the ratio is a measurement.
- ``HINDSIGHT_GEMINI_EXPLICIT_CACHE=1``: enables PR #1936's explicit CachedContent
caching. Then each operation must visibly engage the cache (cached_tokens > 0
and ratio above a conservative floor) this is the regression guard that the
caching actually works end-to-end through the retain/reflect/consolidation paths.
Gated on ``HINDSIGHT_RUN_GEMINI_EVALS=1`` plus a Gemini API key, since it costs
money and needs network. The default model is ``gemini-2.5-flash`` (override with
``HINDSIGHT_GEMINI_EVAL_MODEL``); explicit caching needs a >=2,048-token prefix.
"""
import asyncio
import os
import uuid
import pytest
from hindsight_api import MemoryEngine, RequestContext
from hindsight_api.engine.consolidation.consolidator import run_consolidation_job
from hindsight_api.engine.llm_trace import LLMRequestEntry
from hindsight_api.engine.llm_wrapper import LLMConfig
_GEMINI_API_KEY = (
os.getenv("HINDSIGHT_GEMINI_API_KEY") or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
)
_RUN = os.getenv("HINDSIGHT_RUN_GEMINI_EVALS") == "1" and bool(_GEMINI_API_KEY)
pytestmark = pytest.mark.skipif(
not _RUN,
reason=(
"Gemini implicit-cache measurement is gated. Set HINDSIGHT_RUN_GEMINI_EVALS=1 "
"and provide GEMINI_API_KEY/GOOGLE_API_KEY to run."
),
)
# Number of retain "chunks": each is a separate retain call → a separate
# retain_extract_facts LLM call that re-sends the same ~3k-token system prefix.
# That repetition is the precondition for any caching (implicit or explicit) to
# kick in. Override with HINDSIGHT_GEMINI_CACHE_CHUNKS.
_CHUNKS = int(os.getenv("HINDSIGHT_GEMINI_CACHE_CHUNKS", "5"))
# Distinct paragraphs so each retain extracts real, non-duplicate facts. The
# *content* varies per call; the system prompt / schema prefix does not — which
# is exactly the shape caching targets.
_DOCS = [
"Ada Lovelace worked with Charles Babbage on the Analytical Engine in the 1840s. "
"She wrote what is often considered the first algorithm intended for a machine, "
"a method for computing Bernoulli numbers. She lived in London and corresponded "
"extensively with Babbage about the engine's capabilities.",
"Grace Hopper joined the Harvard Mark I team in 1944 and later developed the first "
"compiler, A-0, in 1952. She championed machine-independent programming languages, "
"which led to COBOL. She served in the US Navy and retired as a rear admiral.",
"Katherine Johnson computed orbital mechanics for NASA's first crewed spaceflights. "
"John Glenn personally asked her to verify the electronic computer's calculations "
"before his 1962 Friendship 7 orbit. She worked at Langley Research Center in Virginia.",
"Alan Turing formalized computation with the Turing machine in 1936 and worked at "
"Bletchley Park during World War II breaking the Enigma cipher. He proposed the "
"imitation game, now called the Turing test, in a 1950 paper on machine intelligence.",
"Margaret Hamilton led the software engineering team that wrote the onboard flight "
"software for the Apollo missions at MIT. Her error-detection code prevented an abort "
"during the Apollo 11 landing in 1969. She later coined the term 'software engineering'.",
"Barbara Liskov designed the CLU programming language in the 1970s and introduced data "
"abstraction. The Liskov substitution principle is named after her. She won the Turing "
"Award in 2008 for contributions to programming language and system design.",
"Tim Berners-Lee invented the World Wide Web in 1989 while at CERN, writing the first "
"browser and the HTTP protocol. He founded the World Wide Web Consortium in 1994 to "
"develop open web standards.",
"Radia Perlman invented the spanning-tree protocol while at Digital Equipment Corporation, "
"which made large bridged Ethernet networks possible. She is sometimes called the mother "
"of the internet, a title she has said she dislikes.",
]
# Explicit Gemini prompt caching (PR #1936) — opt-in. Set HINDSIGHT_GEMINI_EXPLICIT_CACHE=1
# to turn it on for this run. On a branch without the feature the flag is simply
# ignored, so the same test measures the implicit baseline there.
_EXPLICIT_CACHE = os.getenv("HINDSIGHT_GEMINI_EXPLICIT_CACHE") == "1"
async def _gemini_engine(memory_no_llm_verify: MemoryEngine) -> MemoryEngine:
"""Point an engine at real Gemini and force-enable the LLM-request tracer.
The fixture builds the engine with tracing disabled (config default). The
recorder reads ``enabled`` once at construction, so we flip the flag directly
rather than rebuilding the engine equivalent to running with
``HINDSIGHT_API_LLM_TRACE_ENABLED=true``.
When ``HINDSIGHT_GEMINI_EXPLICIT_CACHE=1`` we also enable PR #1936's explicit
CachedContent caching the production way (env var + config-cache clear), so we
can compare its cached/input ratio against the implicit baseline. Otherwise the
only caching observed is Gemini's own implicit caching.
"""
from hindsight_api.config import clear_config_cache
model = os.getenv("HINDSIGHT_GEMINI_EVAL_MODEL", "gemini-2.5-flash")
# Prompt caching is on by default now, so the implicit-baseline run must
# explicitly DISABLE it (not just leave it unset) to measure Gemini's own
# implicit caching. Set the flag in both modes and clear the config cache so
# the per-bank resolver re-reads it.
os.environ["HINDSIGHT_API_LLM_PROMPT_CACHE_ENABLED"] = "true" if _EXPLICIT_CACHE else "false"
clear_config_cache()
cfg = LLMConfig(
provider="gemini",
api_key=_GEMINI_API_KEY or "",
base_url="",
model=model,
prompt_cache_enabled=_EXPLICIT_CACHE,
)
memory_no_llm_verify._llm_config = cfg
memory_no_llm_verify._retain_llm_config = cfg
memory_no_llm_verify._reflect_llm_config = cfg
memory_no_llm_verify._consolidation_llm_config = cfg
memory_no_llm_verify._llm_recorder._enabled = True
mode = "EXPLICIT cache ON" if _EXPLICIT_CACHE else "implicit only"
print(f"\n[gemini-cache] provider=gemini model={model} chunks={_CHUNKS} mode={mode}")
return memory_no_llm_verify
async def _drain_traces(mem: MemoryEngine) -> None:
"""Wait for the recorder's fire-and-forget trace writes to land.
record_llm_call schedules each INSERT as a detached asyncio task tracked in
``_pending`` (bucketed by trace_id). Gather them so the rows are queryable.
Loop a few times because consolidation's attach_memory_ids can spawn a
follow-up write after the first drain.
"""
await mem.wait_for_background_tasks()
rec = mem._llm_recorder
for _ in range(10):
pending = [t for bucket in rec._pending.values() for t in bucket if not t.done()]
if not pending:
break
await asyncio.gather(*pending, return_exceptions=True)
def _report(scope: str, rows: list[LLMRequestEntry]) -> float:
"""Print the cached/input token ratio for a scope and return it.
Gemini's ``prompt_token_count`` (our ``input_tokens``) already includes the
cached prefix, so ``cached_tokens / input_tokens`` is the fraction of prompt
tokens billed at the cheaper cached rate the number the PR's
``cached_input / input`` dashboard would show.
"""
input_total = sum((r.input_tokens or 0) for r in rows)
cached_total = sum((r.cached_tokens or 0) for r in rows)
output_total = sum((r.output_tokens or 0) for r in rows)
ratio = (cached_total / input_total) if input_total else 0.0
per_call = ", ".join(f"{(r.cached_tokens or 0)}/{(r.input_tokens or 0)}" for r in rows)
mode = "explicit cache ON" if _EXPLICIT_CACHE else "implicit only"
print(
f"\n[gemini-cache] scope={scope!r} calls={len(rows)} ({mode})\n"
f" input_tokens = {input_total}\n"
f" cached_tokens = {cached_total}\n"
f" output_tokens = {output_total}\n"
f" cached/input = {ratio:.1%}\n"
f" per-call cached/input: {per_call}"
)
return ratio
@pytest.mark.hs_llm_core
class TestGeminiCacheRatioPerOperation:
"""Measure cached/input token ratio per operation (retain, reflect, consolidation).
Run with ``HINDSIGHT_GEMINI_EXPLICIT_CACHE=1`` to assert PR #1936's explicit
CachedContent caching actually engages (cached tokens > 0, ratio above a
conservative floor). Without it, the same tests record the implicit-caching
baseline (Gemini gives ~0% for this access pattern) without asserting a floor.
"""
async def _fetch(self, mem: MemoryEngine, bank_id: str, rc: RequestContext, scope: str) -> list[LLMRequestEntry]:
resp = await mem.list_llm_requests(bank_id, request_context=rc, scope=scope, limit=200)
assert resp is not None, "bank should exist"
return [r for r in resp.items if r.status == "success"]
def _assert(self, scope: str, rows: list[LLMRequestEntry], *, min_calls: int, min_ratio: float) -> float:
"""Common per-operation checks; returns the cached/input ratio.
``min_ratio`` is per-operation because the achievable ratio differs by
design: retain re-sends a pure fixed prefix (~90%); consolidation's prefix
is fixed but the facts/observations payload is large (~30%); reflect can
only cache its ``auto`` iterations Gemini forbids ``cached_content`` with
a per-request ``tool_config`` and the tool-result context grows, so the
ratio is modest (~10%). The universal guarantee in explicit mode is simply
that caching engaged at all (cached_tokens > 0).
"""
ratio = _report(scope, rows)
cached_total = sum((r.cached_tokens or 0) for r in rows)
assert len(rows) >= min_calls, f"expected >= {min_calls} {scope} calls, got {len(rows)}"
assert all((r.provider == "gemini") for r in rows)
assert sum((r.input_tokens or 0) for r in rows) > 0, "no input tokens recorded"
assert 0.0 <= ratio <= 1.0
if _EXPLICIT_CACHE:
assert cached_total > 0, f"{scope}: explicit cache ON but cached_tokens=0 (caching did not engage)"
assert ratio >= min_ratio, f"{scope}: cached/input {ratio:.1%} below floor {min_ratio:.0%}"
return ratio
async def test_retain_chunks_cached_ratio(self, memory_no_llm_verify, request_context):
"""Retain N distinct chunks → N fact-extraction calls sharing one prefix."""
mem = await _gemini_engine(memory_no_llm_verify)
bank_id = f"gemini-cache-retain-{uuid.uuid4().hex[:8]}"
await mem.get_bank_profile(bank_id, request_context=request_context)
docs = [_DOCS[i % len(_DOCS)] for i in range(_CHUNKS)]
for i, content in enumerate(docs):
await mem.retain_batch_async(
bank_id=bank_id,
contents=[{"content": content}],
request_context=request_context,
document_id=f"doc-{i}",
)
await _drain_traces(mem)
rows = await self._fetch(mem, bank_id, request_context, "retain_extract_facts")
self._assert("retain_extract_facts", rows, min_calls=_CHUNKS, min_ratio=0.5)
await mem.delete_bank(bank_id, request_context=request_context)
async def test_reflect_tool_loop_cached_ratio(self, memory_no_llm_verify, request_context):
"""Reflect runs an agentic tool loop; the system_prompt + tools prefix is
cached once and reused across every iteration (scope ``reflect_tool_call``)."""
mem = await _gemini_engine(memory_no_llm_verify)
bank_id = f"gemini-cache-reflect-{uuid.uuid4().hex[:8]}"
await mem.get_bank_profile(bank_id, request_context=request_context)
await mem.retain_batch_async(
bank_id=bank_id,
contents=[{"content": d} for d in _DOCS],
request_context=request_context,
)
await mem.wait_for_background_tasks()
# A broad question forces the agent to call recall/lookup tools, i.e. to
# iterate the tool loop more than once.
await mem.reflect_async(
bank_id=bank_id,
query="Who were the early pioneers of computing in these memories, and what is each one known for?",
request_context=request_context,
)
await _drain_traces(mem)
rows = await self._fetch(mem, bank_id, request_context, "reflect_tool_call")
if not rows:
# Diagnostic: dump every reflect_tool_call row (incl. errors) so a
# failure in the cached tool-loop path is visible, not silently skipped.
allresp = await mem.list_llm_requests(
bank_id, request_context=request_context, scope="reflect_tool_call", limit=200
)
for r in allresp.items if allresp else []:
print(f"\n[gemini-cache] reflect_tool_call status={r.status} error={r.error}")
pytest.skip("reflect made no SUCCESSFUL reflect_tool_call iterations for this seed")
self._assert("reflect_tool_call", rows, min_calls=1, min_ratio=0.0)
await mem.delete_bank(bank_id, request_context=request_context)
async def test_consolidation_cached_ratio(self, memory_no_llm_verify, request_context):
"""Retain a batch, then consolidate; the stable system prefix is cached and
reused across every consolidation batch (scope ``consolidation``)."""
mem = await _gemini_engine(memory_no_llm_verify)
bank_id = f"gemini-cache-consol-{uuid.uuid4().hex[:8]}"
await mem.get_bank_profile(bank_id, request_context=request_context)
# Seed enough unconsolidated memories that consolidation makes several
# same-prefix LLM calls.
await mem.retain_batch_async(
bank_id=bank_id,
contents=[{"content": d} for d in _DOCS],
request_context=request_context,
)
await mem.wait_for_background_tasks()
await run_consolidation_job(mem, bank_id, request_context)
await _drain_traces(mem)
rows = await self._fetch(mem, bank_id, request_context, "consolidation")
if not rows:
pytest.skip("consolidation made no LLM calls for this seed (nothing to consolidate)")
self._assert("consolidation", rows, min_calls=1, min_ratio=0.15)
await mem.delete_bank(bank_id, request_context=request_context)
@@ -186,8 +186,8 @@ async def test_graph_document_filter_includes_observations_via_source_memories(
# World fact tied to the document.
await conn.execute(
"""
INSERT INTO memory_units (id, bank_id, text, fact_type, document_id, history)
VALUES ($1, $2, $3, 'world', $4, '[]'::jsonb)
INSERT INTO memory_units (id, bank_id, text, fact_type, document_id)
VALUES ($1, $2, $3, 'world', $4)
""",
fact_id,
bank_id,
@@ -198,8 +198,8 @@ async def test_graph_document_filter_includes_observations_via_source_memories(
# Unrelated fact NOT tied to the document.
await conn.execute(
"""
INSERT INTO memory_units (id, bank_id, text, fact_type, history)
VALUES ($1, $2, $3, 'world', '[]'::jsonb)
INSERT INTO memory_units (id, bank_id, text, fact_type)
VALUES ($1, $2, $3, 'world')
""",
other_fact_id,
bank_id,
@@ -210,9 +210,9 @@ async def test_graph_document_filter_includes_observations_via_source_memories(
await conn.execute(
"""
INSERT INTO memory_units (
id, bank_id, text, fact_type, source_memory_ids, history, proof_count
id, bank_id, text, fact_type, source_memory_ids, proof_count
)
VALUES ($1, $2, $3, 'observation', $4::uuid[], '[]'::jsonb, 1)
VALUES ($1, $2, $3, 'observation', $4::uuid[], 1)
""",
observation_id,
bank_id,
@@ -224,9 +224,9 @@ async def test_graph_document_filter_includes_observations_via_source_memories(
await conn.execute(
"""
INSERT INTO memory_units (
id, bank_id, text, fact_type, source_memory_ids, history, proof_count
id, bank_id, text, fact_type, source_memory_ids, proof_count
)
VALUES ($1, $2, $3, 'observation', $4::uuid[], '[]'::jsonb, 1)
VALUES ($1, $2, $3, 'observation', $4::uuid[], 1)
""",
unrelated_observation_id,
bank_id,
@@ -38,11 +38,17 @@ class MockTenantExtension(TenantExtension):
return self.tenant_config
class _FakeBankOps:
async def create_bank_vector_indexes(self, *args, **kwargs):
return None
class FakeBankConfigBackend:
"""Minimal backend for ConfigResolver bank-config tests."""
def __init__(self):
self.config: dict[str, object] = {}
self.ops = _FakeBankOps()
def acquire(self):
return FakeBankConfigConnection(self)
@@ -61,6 +67,11 @@ class FakeBankConfigConnection:
async def fetchrow(self, query, bank_id):
return {"config": self.backend.config}
async def fetchval(self, query, *args):
# ensure_bank_exists INSERT ... ON CONFLICT DO NOTHING RETURNING bank_id.
# Return None to simulate the bank already existing (no index creation).
return None
async def execute(self, query, updates_json, bank_id):
self.backend.config.update(json.loads(updates_json))
@@ -231,7 +231,13 @@ async def test_horse_farm_observation_history(memory_real_llm: MemoryEngine, req
async with pool.acquire() as conn:
observations = await conn.fetch(
"""
SELECT id, text, proof_count, source_memory_ids, history
SELECT id, text, proof_count, source_memory_ids,
COALESCE((
SELECT jsonb_agg(jsonb_build_object('previous_text', oh.content->>'previous_text')
ORDER BY oh.changed_at, oh.id)
FROM observation_history oh
WHERE oh.observation_id = memory_units.id
), '[]'::jsonb) AS history
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
ORDER BY created_at
@@ -255,7 +261,13 @@ async def test_horse_farm_observation_history(memory_real_llm: MemoryEngine, req
async with pool.acquire() as conn:
observations = await conn.fetch(
"""
SELECT id, text, proof_count, source_memory_ids, history
SELECT id, text, proof_count, source_memory_ids,
COALESCE((
SELECT jsonb_agg(jsonb_build_object('previous_text', oh.content->>'previous_text')
ORDER BY oh.changed_at, oh.id)
FROM observation_history oh
WHERE oh.observation_id = memory_units.id
), '[]'::jsonb) AS history
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'observation'
ORDER BY created_at
@@ -1104,6 +1104,8 @@ async def test_version_endpoint_returns_correct_version(api_client):
assert isinstance(features["observations"], bool)
assert isinstance(features["mcp"], bool)
assert isinstance(features["worker"], bool)
assert isinstance(features["audit_log"], bool)
assert isinstance(features["llm_trace"], bool)
print(f"Version endpoint returned: api_version={result['api_version']}, features={features}")
@@ -1315,6 +1317,42 @@ async def test_unknown_params_not_rejected(api_client):
assert "X-Ignored-Params" not in response.headers
@pytest.mark.asyncio
@pytest.mark.parametrize("field", ["enable_observations", "enable_auto_consolidation"])
async def test_patch_config_persists_override_for_uncreated_bank(api_client, field):
"""PATCH config must persist the override even when the bank was never retained.
Banks are created lazily on first retain, so a PATCH that precedes any
ingestion previously UPDATE-d zero rows and silently no-op'd while returning
200 (issue #1940). The endpoint must auto-create the bank and round-trip the
override in the same response.
"""
test_bank_id = f"patch_uncreated_{field}_{datetime.now().timestamp()}"
# PATCH config without ever creating the bank (no PUT, no retain).
response = await api_client.patch(
f"/v1/default/banks/{test_bank_id}/config",
json={"updates": {field: False}},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["config"][field] is False
assert body["overrides"].get(field) is False
# GET reads back the persisted override (proves it was written, not just echoed).
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/config")
assert response.status_code == 200, response.text
body = response.json()
assert body["config"][field] is False
assert body["overrides"].get(field) is False
# The auto-created bank must have a name (defaults to bank_id). A NULL name
# would 500 the profile endpoint, whose response types name as a required str.
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
assert response.status_code == 200, response.text
assert response.json()["name"] == test_bank_id
@pytest.mark.hs_llm_core
@pytest.mark.asyncio
async def test_full_api_workflow_llm_quality(api_client_real_llm):
@@ -0,0 +1,61 @@
"""Unit tests for round-robin interleave fusion (consolidation dedup recall)."""
from hindsight_api.engine.search.fusion import interleave_fusion
from hindsight_api.engine.search.types import RetrievalResult
def _r(doc_id: str) -> RetrievalResult:
return RetrievalResult(id=doc_id, text=f"text-{doc_id}", fact_type="observation")
def _ids(merged) -> list[str]:
return [mc.id for mc in merged]
def test_round_robin_order_takes_each_arm_in_turn():
semantic = [_r("s1"), _r("s2"), _r("s3")]
bm25 = [_r("b1"), _r("b2")]
graph = [_r("g1")]
merged = interleave_fusion([semantic, bm25, graph])
# Round 0: s1, b1, g1 ; round 1: s2, b2 ; round 2: s3
assert _ids(merged) == ["s1", "b1", "g1", "s2", "b2", "s3"]
def test_semantic_top_hit_is_always_first():
# The dedup twin: semantic #1 but absent from every other arm. Must still lead.
semantic = [_r("twin"), _r("s2")]
bm25 = [_r("b1"), _r("b2"), _r("b3")]
graph = [_r("g1")]
merged = interleave_fusion([semantic, bm25, graph])
assert merged[0].id == "twin"
def test_dedup_keeps_first_occurrence_and_records_all_arm_ranks():
# "x" is semantic #1 and bm25 #2; it should appear once, at its first (semantic) slot,
# but carry ranks from every arm it appears in.
semantic = [_r("x"), _r("s2")]
bm25 = [_r("b1"), _r("x")]
merged = interleave_fusion([semantic, bm25])
assert _ids(merged) == ["x", "b1", "s2"]
x = next(mc for mc in merged if mc.id == "x")
assert x.source_ranks == {"semantic_rank": 1, "bm25_rank": 2}
def test_rrf_score_strictly_decreasing_preserves_order_on_sort():
merged = interleave_fusion([[_r("a"), _r("b")], [_r("c")]])
scores = [mc.rrf_score for mc in merged]
assert scores == sorted(scores, reverse=True)
assert len(set(scores)) == len(scores) # strictly decreasing, no ties
# rrf_rank reflects the interleave position
assert [mc.rrf_rank for mc in merged] == [1, 2, 3]
def test_empty_inputs():
assert interleave_fusion([]) == []
assert interleave_fusion([[], []]) == []
@@ -104,6 +104,35 @@ class TestLiteLLMSDKCrossEncoder:
assert len(call_args.kwargs["documents"]) == 3
assert call_args.kwargs["api_key"] == "test_key"
def test_constructor_without_api_key(self):
"""api_key is optional (e.g. AWS Bedrock reranker with ambient IAM creds)."""
encoder = LiteLLMSDKCrossEncoder(model="bedrock/cohere.rerank-v3-5:0")
assert encoder.api_key is None
@pytest.mark.asyncio
async def test_predict_omits_api_key_for_ambient_credentials(self):
"""When no api_key is set, it must not be injected into the rerank call.
litellm maps an explicit ``api_key`` to ``aws_access_key_id`` for Bedrock,
which overrides ambient IAM/task-role credentials; omitting it lets litellm
resolve credentials from the environment (regression test for IAM auth).
"""
encoder = LiteLLMSDKCrossEncoder(model="bedrock/cohere.rerank-v3-5:0")
mock_response = MagicMock()
mock_response.results = [{"index": 0, "relevance_score": 0.9}]
mock_litellm = MagicMock()
mock_litellm.arerank = AsyncMock(return_value=mock_response)
with patch.dict("sys.modules", {"litellm": mock_litellm}):
await encoder.initialize()
await encoder.predict([("query", "document")])
mock_litellm.arerank.assert_called_once()
call_kwargs = mock_litellm.arerank.call_args.kwargs
assert "api_key" not in call_kwargs
@pytest.mark.asyncio
async def test_predict_multiple_queries(self):
"""Test prediction with multiple different queries (grouped efficiently)."""
@@ -278,11 +307,11 @@ class TestFactoryFunction:
assert encoder.model == "deepinfra/Qwen3-reranker-8B"
@pytest.mark.asyncio
async def test_create_litellm_sdk_missing_api_key(self):
"""Test that factory raises error when API key is missing."""
async def test_create_litellm_sdk_without_api_key(self):
"""Test that litellm-sdk works without an API key (e.g. AWS Bedrock with IAM)."""
env_vars = {
"HINDSIGHT_API_RERANKER_PROVIDER": "litellm-sdk",
"HINDSIGHT_API_RERANKER_LITELLM_SDK_MODEL": "deepinfra/Qwen3-reranker-8B",
"HINDSIGHT_API_RERANKER_LITELLM_SDK_MODEL": "bedrock/cohere.rerank-v3-5:0",
}
with patch.dict(os.environ, env_vars, clear=False):
@@ -295,8 +324,11 @@ class TestFactoryFunction:
config = HindsightConfig.from_env()
with patch("hindsight_api.config.get_config", return_value=config):
with pytest.raises(ValueError, match="HINDSIGHT_API_RERANKER_LITELLM_SDK_API_KEY is required"):
create_cross_encoder_from_env()
encoder = create_cross_encoder_from_env()
assert isinstance(encoder, LiteLLMSDKCrossEncoder)
assert encoder.api_key is None
assert encoder.model == "bedrock/cohere.rerank-v3-5:0"
@pytest.mark.asyncio
async def test_create_litellm_sdk_with_custom_api_base(self):
@@ -454,7 +454,7 @@ class TestLiteLLMSDKEmbeddings:
):
emb = LiteLLMSDKEmbeddings(
api_key="test_key",
model="gemini/gemini-embedding-2-preview",
model="gemini/gemini-embedding-2",
encoding_format="",
)
await emb.initialize()
@@ -542,7 +542,7 @@ class TestLiteLLMSDKEmbeddingsFactory:
mock_config = MagicMock()
mock_config.embeddings_provider = "litellm-sdk"
mock_config.embeddings_litellm_sdk_api_key = "test_key"
mock_config.embeddings_litellm_sdk_model = "gemini/gemini-embedding-2-preview"
mock_config.embeddings_litellm_sdk_model = "gemini/gemini-embedding-2"
mock_config.embeddings_litellm_sdk_api_base = None
mock_config.embeddings_litellm_sdk_output_dimensions = 768
@@ -0,0 +1,254 @@
"""
Tests for the ``llm_extra_body`` knob (env: ``HINDSIGHT_API_LLM_EXTRA_BODY``).
The same JSON dict of extra request-body params is threaded into every API
provider. Each provider merges it in its own native parameter space:
- OpenAI-compatible / Fireworks: OpenAI SDK ``extra_body`` (already covered
elsewhere; the wiring predates this change).
- Anthropic: the Anthropic SDK ``extra_body`` kwarg.
- Gemini / VertexAI: seeded into ``GenerateContentConfig`` (the SDK's native
generation-param space Gemini nests these in the request body).
- LiteLLM (+ bedrock alias + router): merged as top-level ``acompletion`` kwargs
so LiteLLM normalizes/drops them per-provider.
These are deterministic unit tests: the SDK client is mocked and we assert the
params actually reach the call.
"""
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
EXTRA_BODY = {"temperature": 0.2, "top_p": 0.9}
# ─── config / env parsing ─────────────────────────────────────────────────────
def test_extra_body_parsed_from_env():
"""The JSON env var is parsed into ``HindsightConfig.llm_extra_body``."""
import json
from hindsight_api.config import ENV_LLM_EXTRA_BODY, HindsightConfig, clear_config_cache
with patch.dict(os.environ, {ENV_LLM_EXTRA_BODY: json.dumps(EXTRA_BODY)}, clear=False):
clear_config_cache()
config = HindsightConfig.from_env()
assert config.llm_extra_body == EXTRA_BODY
clear_config_cache()
def test_extra_body_default_is_none():
"""When the env var is unset, ``llm_extra_body`` defaults to None."""
from hindsight_api.config import ENV_LLM_EXTRA_BODY, HindsightConfig, clear_config_cache
env = {k: v for k, v in os.environ.items() if k != ENV_LLM_EXTRA_BODY}
with patch.dict(os.environ, env, clear=True):
clear_config_cache()
config = HindsightConfig.from_env()
assert config.llm_extra_body is None
clear_config_cache()
# ─── Anthropic ────────────────────────────────────────────────────────────────
def _make_anthropic_provider(extra_body=None):
with patch("anthropic.AsyncAnthropic") as mock_client_cls:
mock_client_cls.return_value = MagicMock()
from hindsight_api.engine.providers.anthropic_llm import AnthropicLLM
provider = AnthropicLLM(
provider="anthropic",
api_key="fake-key",
base_url="",
model="claude-sonnet-4-20250514",
extra_body=extra_body,
)
provider._client = MagicMock()
return provider
def _fake_anthropic_response():
block = MagicMock()
block.type = "text"
block.text = "ok"
resp = MagicMock()
resp.content = [block]
resp.usage = MagicMock(input_tokens=5, output_tokens=2, cache_read_input_tokens=0)
resp.stop_reason = "end_turn"
return resp
def test_anthropic_stores_extra_body():
provider = _make_anthropic_provider(extra_body=EXTRA_BODY)
assert provider._extra_body == EXTRA_BODY
def test_anthropic_empty_extra_body_defaults_to_dict():
provider = _make_anthropic_provider(extra_body=None)
assert provider._extra_body == {}
@pytest.mark.asyncio
async def test_anthropic_call_passes_extra_body():
"""``call()`` forwards extra_body via the Anthropic SDK ``extra_body`` kwarg."""
provider = _make_anthropic_provider(extra_body=EXTRA_BODY)
provider._client.messages.create = AsyncMock(return_value=_fake_anthropic_response())
with patch("hindsight_api.engine.providers.anthropic_llm.get_metrics_collector"):
await provider.call(messages=[{"role": "user", "content": "hi"}], scope="test", max_retries=0)
kwargs = provider._client.messages.create.call_args.kwargs
assert kwargs.get("extra_body") == EXTRA_BODY
@pytest.mark.asyncio
async def test_anthropic_no_extra_body_omits_key():
"""``call()`` does not pass ``extra_body`` when none is configured."""
provider = _make_anthropic_provider(extra_body=None)
provider._client.messages.create = AsyncMock(return_value=_fake_anthropic_response())
with patch("hindsight_api.engine.providers.anthropic_llm.get_metrics_collector"):
await provider.call(messages=[{"role": "user", "content": "hi"}], scope="test", max_retries=0)
assert "extra_body" not in provider._client.messages.create.call_args.kwargs
# ─── Gemini ───────────────────────────────────────────────────────────────────
def _make_gemini_provider(extra_body=None):
pytest.importorskip("google.genai")
with patch("google.genai.Client") as mock_client_cls:
mock_client_cls.return_value = MagicMock()
from hindsight_api.engine.providers.gemini_llm import GeminiLLM
provider = GeminiLLM(
provider="gemini",
api_key="fake-key",
base_url="",
model="gemini-2.5-flash",
extra_body=extra_body,
)
provider._client = MagicMock()
return provider
def _fake_gemini_response():
r = MagicMock()
r.text = "hello"
r.candidates = [MagicMock(finish_reason="STOP")]
r.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=2)
return r
def test_gemini_stores_extra_body():
provider = _make_gemini_provider(extra_body=EXTRA_BODY)
assert provider._extra_body == EXTRA_BODY
@pytest.mark.asyncio
async def test_gemini_call_applies_extra_body_to_generation_config():
"""``call()`` seeds extra_body into GenerateContentConfig (temperature/top_p)."""
provider = _make_gemini_provider(extra_body=EXTRA_BODY)
provider._client.aio.models.generate_content = AsyncMock(return_value=_fake_gemini_response())
await provider.call(messages=[{"role": "user", "content": "hi"}], scope="test")
config_arg = provider._client.aio.models.generate_content.call_args.kwargs.get("config")
assert config_arg is not None
assert config_arg.temperature == 0.2
assert config_arg.top_p == 0.9
@pytest.mark.asyncio
async def test_gemini_explicit_temperature_overrides_extra_body():
"""An explicit per-call temperature wins over the extra_body default."""
provider = _make_gemini_provider(extra_body={"temperature": 0.2})
provider._client.aio.models.generate_content = AsyncMock(return_value=_fake_gemini_response())
await provider.call(messages=[{"role": "user", "content": "hi"}], temperature=0.9, scope="test")
config_arg = provider._client.aio.models.generate_content.call_args.kwargs.get("config")
assert config_arg.temperature == 0.9
# ─── LiteLLM ──────────────────────────────────────────────────────────────────
def _make_litellm_provider(extra_body=None):
pytest.importorskip("litellm")
from hindsight_api.engine.providers.litellm_llm import LiteLLMLLM
return LiteLLMLLM(
provider="litellm",
api_key="fake-key",
base_url="",
model="gpt-4o",
extra_body=extra_body,
)
def _fake_litellm_response():
msg = MagicMock()
msg.content = "ok"
choice = MagicMock()
choice.message = msg
choice.finish_reason = "stop"
resp = MagicMock()
resp.choices = [choice]
resp.usage = MagicMock(prompt_tokens=5, completion_tokens=2)
return resp
def test_litellm_stores_extra_body():
provider = _make_litellm_provider(extra_body=EXTRA_BODY)
assert provider._extra_body == EXTRA_BODY
@pytest.mark.asyncio
async def test_litellm_call_merges_extra_body_as_top_level_kwargs():
"""``call()`` merges extra_body into the acompletion kwargs."""
provider = _make_litellm_provider(extra_body=EXTRA_BODY)
provider._acompletion = AsyncMock(return_value=_fake_litellm_response())
with patch("hindsight_api.engine.providers.litellm_llm.get_metrics_collector"):
await provider.call(messages=[{"role": "user", "content": "hi"}], scope="test", max_retries=0)
kwargs = provider._acompletion.call_args.kwargs
assert kwargs.get("temperature") == 0.2
assert kwargs.get("top_p") == 0.9
@pytest.mark.asyncio
async def test_litellm_explicit_param_wins_over_extra_body():
"""``setdefault`` semantics: an explicit per-call value is not overwritten."""
provider = _make_litellm_provider(extra_body={"temperature": 0.2})
provider._acompletion = AsyncMock(return_value=_fake_litellm_response())
with patch("hindsight_api.engine.providers.litellm_llm.get_metrics_collector"):
await provider.call(
messages=[{"role": "user", "content": "hi"}], temperature=0.9, scope="test", max_retries=0
)
assert provider._acompletion.call_args.kwargs.get("temperature") == 0.9
def test_litellm_router_forwards_extra_body():
"""The Router subclass forwards extra_body through to the shared LiteLLM base."""
pytest.importorskip("litellm")
from hindsight_api.engine.providers.litellm_router_llm import LiteLLMRouterLLM
config = {"model_list": [{"model_name": "m", "litellm_params": {"model": "gpt-4o", "api_key": "x"}}]}
provider = LiteLLMRouterLLM(
provider="litellmrouter",
api_key="",
base_url="",
model="m",
config=config,
extra_body=EXTRA_BODY,
)
assert provider._extra_body == EXTRA_BODY
@@ -41,7 +41,7 @@ def two_step_config() -> dict[str, Any]:
{
"model_name": "default",
"litellm_params": {
"model": "openai/MiniMax-M2.7",
"model": "openai/MiniMax-M3",
"api_key": "sk-primary",
"api_base": "https://api.minimax.io/v1",
},
@@ -0,0 +1,190 @@
"""
Tests for HINDSIGHT_API_LLM_STRICT_SCHEMA / config.llm_strict_schema.
The flag asks every provider for its strongest structured-output mode so weaker
self-hosted instruction-followers can't wedge retain/consolidation by emitting
prose preambles, markdown ```json fences, or invalid JSON that fails parsing.
It is resolved once in ``LLMProvider.call`` (OR-ed with the per-call
``strict_schema`` argument) and passed down, so each provider honours it through
its existing ``strict_schema`` handling:
- OpenAI-compatible / LiteLLM: ``response_format`` ``json_schema`` with ``strict: true``
- Gemini: already grammar-enforces its native ``response_schema`` (flag is a no-op)
- Providers without a strict mode ignore it.
The batch retain path builds its request body directly (bypassing ``call``), so
it reads the config flag itself.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from pydantic import BaseModel
from hindsight_api.config import ENV_LLM_STRICT_SCHEMA, HindsightConfig
from hindsight_api.engine.llm_wrapper import LLMProvider
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
class _Resp(BaseModel):
ok: bool
def _config_with(strict: bool) -> object:
"""A config proxy that overrides only llm_strict_schema (avoids recursion)."""
from hindsight_api.config import get_config
real = get_config()
class _Cfg:
llm_strict_schema = strict
def __getattr__(self, name):
return getattr(real, name)
return _Cfg()
# --------------------------------------------------------------------------- #
# config
# --------------------------------------------------------------------------- #
def test_env_var_enables_strict_schema(monkeypatch):
monkeypatch.setenv(ENV_LLM_STRICT_SCHEMA, "true")
assert HindsightConfig.from_env().llm_strict_schema is True
def test_strict_schema_defaults_off(monkeypatch):
monkeypatch.delenv(ENV_LLM_STRICT_SCHEMA, raising=False)
assert HindsightConfig.from_env().llm_strict_schema is False
# --------------------------------------------------------------------------- #
# wrapper: resolves the flag for every provider
# --------------------------------------------------------------------------- #
async def _strict_passed_to_provider(*, config_flag: bool, call_arg: bool) -> bool:
"""Return the strict_schema value the wrapper forwards to the provider impl."""
llm = LLMProvider(provider="anthropic", api_key="test-key", base_url="", model="claude-x")
impl = SimpleNamespace(call=AsyncMock(return_value=_Resp(ok=True)))
llm._provider_impl = impl
cfg = _config_with(config_flag) # build before patching to avoid get_config recursion
with patch("hindsight_api.config.get_config", lambda: cfg):
await llm.call(
messages=[{"role": "user", "content": "hi"}],
response_format=_Resp,
strict_schema=call_arg,
max_retries=0,
)
return impl.call.call_args.kwargs["strict_schema"]
@pytest.mark.asyncio
async def test_wrapper_ors_in_config_flag():
# Config flag on, caller didn't ask → provider still gets strict.
assert await _strict_passed_to_provider(config_flag=True, call_arg=False) is True
@pytest.mark.asyncio
async def test_wrapper_off_by_default():
assert await _strict_passed_to_provider(config_flag=False, call_arg=False) is False
@pytest.mark.asyncio
async def test_wrapper_per_call_override_still_works_with_flag_off():
assert await _strict_passed_to_provider(config_flag=False, call_arg=True) is True
# --------------------------------------------------------------------------- #
# openai-compatible: strict_schema -> json_schema, else json_object
# --------------------------------------------------------------------------- #
def _openai_response(content: str = '{"ok": true}'):
choice = SimpleNamespace(
finish_reason="stop", message=SimpleNamespace(content=content, tool_calls=None, refusal=None)
)
return SimpleNamespace(error=None, usage=None, choices=[choice])
async def _openai_response_format(*, strict: bool):
llm = OpenAICompatibleLLM(
provider="openai", api_key="test-key", base_url="https://example.test/v1", model="gpt-4o-mini"
)
create = AsyncMock(return_value=_openai_response())
llm._client.chat.completions.create = create
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
await llm.call(
messages=[{"role": "user", "content": "Return whether this worked."}],
response_format=_Resp,
strict_schema=strict,
max_retries=0,
)
return create.call_args.kwargs.get("response_format")
@pytest.mark.asyncio
async def test_openai_strict_uses_json_schema():
rf = await _openai_response_format(strict=True)
assert rf is not None and rf["type"] == "json_schema"
assert rf["json_schema"]["strict"] is True
@pytest.mark.asyncio
async def test_openai_soft_uses_json_object():
rf = await _openai_response_format(strict=False)
assert rf is not None and rf["type"] == "json_object"
# --------------------------------------------------------------------------- #
# litellm: strict_schema -> response_format strict flag
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("strict", [True, False])
@pytest.mark.asyncio
async def test_litellm_response_format_strict_follows_arg(strict):
from hindsight_api.engine.providers.litellm_llm import LiteLLMLLM
llm = LiteLLMLLM(provider="litellm", api_key="test-key", base_url="", model="gpt-4o-mini")
response = SimpleNamespace(
choices=[SimpleNamespace(finish_reason="stop", message=SimpleNamespace(content='{"ok": true}'))],
usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1),
)
acompletion = AsyncMock(return_value=response)
with (
patch.object(llm, "_acompletion", acompletion),
patch("hindsight_api.engine.providers.litellm_llm.get_metrics_collector"),
):
await llm.call(
messages=[{"role": "user", "content": "Return whether this worked."}],
response_format=_Resp,
strict_schema=strict,
max_retries=0,
)
rf = acompletion.call_args.kwargs["response_format"]
assert rf["type"] == "json_schema"
assert rf["json_schema"]["strict"] is strict
# --------------------------------------------------------------------------- #
# batch retain path: reads the config flag directly
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("strict", [True, False])
def test_batch_request_body_strict_follows_config(strict):
from hindsight_api.engine.retain.fact_extraction import _build_request_body
llm_config = SimpleNamespace(model="gpt-4o-mini", provider="openai", _provider_impl=SimpleNamespace())
config = SimpleNamespace(retain_max_completion_tokens=None, llm_strict_schema=strict)
# provider != "openai" service-tier branch skipped via _provider_impl without attr
llm_config._provider_impl.openai_service_tier = None
body = _build_request_body(llm_config, config, "system prompt", "user message", _Resp)
assert body["response_format"]["json_schema"]["strict"] is strict
+545
View File
@@ -0,0 +1,545 @@
"""Tests for per-bank LLM request tracing.
Capture flows through the OTel GenAI recorder: providers call
``record_llm_call`` on success, and the LLM wrapper forwards failures through
the same recorder. The DB tracer (``LLMTraceRecorder``) is registered as one of
those recorders. Covers serialization, record building, the wrapper
success/error paths, and the HTTP read API (list / stats / tokens).
"""
import asyncio
import json
from datetime import datetime, timezone
import httpx
import pytest
import pytest_asyncio
from pydantic import BaseModel
from hindsight_api import tracing
from hindsight_api.api import create_app
from hindsight_api.engine import llm_trace
from hindsight_api.engine.llm_trace import (
LLMRequestRecord,
LLMTraceContext,
LLMTraceRecorder,
_safe_json,
current_trace_context,
set_trace_context,
)
from hindsight_api.engine.llm_wrapper import LLMProvider
# ── serialization helpers ─────────────────────────────────────────────────────
def test_safe_json_none_returns_none():
assert _safe_json(None, 1000) is None
def test_safe_json_handles_datetime_uuid_set_and_pydantic():
import uuid
class Item(BaseModel):
name: str
data = {
"when": datetime(2026, 1, 1, tzinfo=timezone.utc),
"id": uuid.uuid4(),
"tags": {"a", "b"},
"model": Item(name="x"),
"raw": b"bytes",
}
out = json.loads(_safe_json(data, 100_000))
assert out["when"].startswith("2026-01-01")
assert isinstance(out["id"], str)
assert sorted(out["tags"]) == ["a", "b"]
assert out["model"] == {"name": "x"}
assert out["raw"] == "<bytes>"
def test_safe_json_truncates_oversized_payload_to_valid_json():
big = {"text": "x" * 5000}
parsed = json.loads(_safe_json(big, max_chars=200)) # must stay valid JSON
assert parsed["_truncated"] is True
assert parsed["_original_chars"] > 200
assert len(parsed["preview"]) == 200
def test_context_var_is_unset_by_default():
assert current_trace_context() is None
# ── recorder: enable / allowlist ──────────────────────────────────────────────
def _recorder(enabled=True, allowed=None):
return LLMTraceRecorder(
pool_getter=lambda: None,
schema_getter=lambda: "public",
enabled=enabled,
allowed_scopes=allowed or [],
)
def test_recorder_disabled_is_not_enabled_for_any_scope():
assert _recorder(enabled=False).is_enabled("memory") is False
def test_recorder_scope_allowlist():
r = _recorder(enabled=True, allowed=["reflect"])
assert r.is_enabled("reflect") is True
assert r.is_enabled("retain_extract_facts") is False
# ── recorder: record_llm_call builds correct records ──────────────────────────
class _CapturingRecorder(LLMTraceRecorder):
"""Captures records synchronously instead of writing to the DB."""
def __init__(self, enabled=True, allowed=None):
super().__init__(
pool_getter=lambda: None,
schema_getter=lambda: "public",
enabled=enabled,
allowed_scopes=allowed or [],
)
self.records: list[LLMRequestRecord] = []
def _record_fire_and_forget(self, record: LLMRequestRecord) -> None:
self.records.append(record)
def test_record_llm_call_success_with_context_and_tokens():
rec = _CapturingRecorder()
token = set_trace_context(LLMTraceContext(bank_id="bank-x", operation="retain", metadata={"k": "v"}))
try:
rec.record_llm_call(
provider="gemini",
model="gemini-2.5-flash-lite",
scope="retain_extract_facts",
messages=[{"role": "user", "content": "hi"}],
response_content="some output",
input_tokens=100,
output_tokens=20,
duration=0.5,
finish_reason="stop",
cached_tokens=40,
)
finally:
llm_trace.reset_trace_context(token)
assert len(rec.records) == 1
r = rec.records[0]
assert r.status == "success"
assert r.bank_id == "bank-x"
assert r.operation == "retain"
assert r.metadata == {"k": "v"}
assert r.input_tokens == 100
assert r.output_tokens == 20
assert r.cached_tokens == 40
assert r.total_tokens == 120
assert r.output == "some output"
assert r.llm_info["finish_reason"] == "stop"
def test_record_llm_call_error_record():
rec = _CapturingRecorder()
rec.record_llm_call(
provider="mock",
model="mock",
scope="memory",
messages=[],
error=RuntimeError("boom"),
duration=0.1,
)
assert len(rec.records) == 1
r = rec.records[0]
assert r.status == "error"
assert r.error == "RuntimeError: boom"
assert r.output is None
assert r.bank_id is None
def test_record_llm_call_disabled_scope_records_nothing():
rec = _CapturingRecorder(allowed=["reflect"])
rec.record_llm_call(provider="mock", model="mock", scope="memory", messages=[], duration=0.1)
assert rec.records == []
# ── wrapper: success via provider, error forwarded ────────────────────────────
@pytest.fixture
def registered_recorder():
"""Register a capturing recorder with the GenAI registry for one test."""
rec = _CapturingRecorder()
tracing.register_span_recorder(rec)
yield rec
tracing.unregister_span_recorder(rec)
@pytest.mark.asyncio
async def test_wrapper_success_recorded_by_provider(registered_recorder):
llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock")
result = await llm.call(messages=[{"role": "user", "content": "hello"}], scope="memory")
assert result == "mock response"
assert len(registered_recorder.records) == 1
r = registered_recorder.records[0]
assert r.status == "success"
assert r.provider == "mock"
assert r.scope == "memory"
@pytest.mark.asyncio
async def test_wrapper_error_forwarded_and_reraised(registered_recorder):
llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock")
llm._provider_impl.set_mock_exception(RuntimeError("kaboom"))
with pytest.raises(RuntimeError, match="kaboom"):
await llm.call(messages=[{"role": "user", "content": "x"}], scope="memory")
assert len(registered_recorder.records) == 1
r = registered_recorder.records[0]
assert r.status == "error"
assert "kaboom" in r.error
@pytest.mark.asyncio
async def test_configured_provider_binds_bank_context(registered_recorder):
llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock")
class _Cfg:
llm_gemini_safety_settings = None
configured = llm.with_config(_Cfg(), bank_id="bank-42", operation="reflect")
await configured.call(messages=[{"role": "user", "content": "x"}], scope="memory")
assert len(registered_recorder.records) == 1
assert registered_recorder.records[0].bank_id == "bank-42"
assert registered_recorder.records[0].operation == "reflect"
assert current_trace_context() is None # unwound after the call
# ── HTTP read API (integration) ───────────────────────────────────────────────
@pytest_asyncio.fixture
async def trace_api_client(memory):
"""Test client with LLM tracing enabled on the engine's recorder."""
memory._llm_recorder._enabled = True
memory._llm_recorder._allowed_scopes = None # All scopes
app = create_app(memory, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
memory._llm_recorder._enabled = False
@pytest.fixture
def bank_id():
return f"llm_trace_test_{datetime.now().timestamp()}"
@pytest.mark.asyncio
async def test_list_empty(trace_api_client, bank_id):
await trace_api_client.put(f"/v1/default/banks/{bank_id}", json={"name": "Trace Bank"})
response = await trace_api_client.get(f"/v1/default/banks/{bank_id}/llm-requests")
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["total"] == 0
@pytest.mark.asyncio
async def test_retain_creates_trace_rows_with_tokens(trace_api_client, bank_id):
await trace_api_client.put(f"/v1/default/banks/{bank_id}", json={"name": "Trace Bank"})
response = await trace_api_client.post(
f"/v1/default/banks/{bank_id}/memories",
json={"items": [{"content": "Alice likes cats", "context": "preferences"}]},
)
assert response.status_code == 200
# retain triggers fact extraction + consolidation (2 LLM calls → 2 trace
# writes); give the fire-and-forget tasks room under parallel test load.
await asyncio.sleep(1.5)
response = await trace_api_client.get(f"/v1/default/banks/{bank_id}/llm-requests")
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
operations = {item["operation"] for item in data["items"]}
assert "retain" in operations, f"Expected a 'retain' trace, got: {operations}"
retain_entry = next(it for it in data["items"] if it["operation"] == "retain")
assert retain_entry["status"] == "success"
assert retain_entry["provider"] == "mock"
assert retain_entry["bank_id"] == bank_id
# The mock provider reports token usage via record_llm_call.
assert retain_entry["input_tokens"] is not None
assert retain_entry["total_tokens"] is not None
# Requested params are captured (retain sets retain_max_completion_tokens=64000).
assert retain_entry["llm_info"]["request"]["max_completion_tokens"] == 64000
# The retain extraction call is attributed to its document, and the
# document_id filter returns that run's calls.
doc_id = retain_entry["metadata"]["document_id"]
assert doc_id
by_doc = (
await trace_api_client.get(f"/v1/default/banks/{bank_id}/llm-requests", params={"document_id": doc_id})
).json()
assert by_doc["total"] >= 1
assert all(it["metadata"].get("document_id") == doc_id for it in by_doc["items"])
entry = data["items"][0]
# OTel-style grouping: every row carries trace ids, and each operation
# invocation (retain vs consolidation) gets its own trace_id, while a call's
# parent_span_id is its operation span.
by_op = {item["operation"]: item for item in data["items"]}
for item in data["items"]:
assert item["trace_id"] and item["span_id"] and item["parent_span_id"]
if "retain" in by_op and "consolidation" in by_op:
assert by_op["retain"]["trace_id"] != by_op["consolidation"]["trace_id"]
# Filtering by a trace_id returns only that operation run's calls.
a_trace = entry["trace_id"]
resp = await trace_api_client.get(
f"/v1/default/banks/{bank_id}/llm-requests", params={"trace_id": a_trace}
)
assert resp.status_code == 200
filtered = resp.json()
assert filtered["total"] >= 1
assert all(it["trace_id"] == a_trace for it in filtered["items"])
# group=true paginates by run: total counts distinct runs (here retain +
# consolidation = 2), and every traced row is still returned.
resp = await trace_api_client.get(f"/v1/default/banks/{bank_id}/llm-requests", params={"group": "true"})
assert resp.status_code == 200
grouped = resp.json()
distinct_traces = {it["trace_id"] for it in data["items"]}
assert grouped["total"] == len(distinct_traces)
assert len(grouped["items"]) >= len(data["items"])
@pytest.mark.asyncio
async def test_delta_reretain_binds_document_id(trace_api_client, bank_id):
"""A delta re-retain (editing/appending a document) must tag its trace with
the document_id, so the document accrues one trace per retain not just the
initial full retain. Regression: the delta path used a second extraction call
site that bypassed the document_id attribution, so edits were orphaned.
"""
await trace_api_client.put(f"/v1/default/banks/{bank_id}", json={"name": "Trace Bank"})
document_id = "delta-doc-001"
# v1: a single self-contained chunk.
v1 = "Alice is a software engineer at Google. She works on search infrastructure."
resp = await trace_api_client.post(
f"/v1/default/banks/{bank_id}/memories",
json={"items": [{"content": v1, "context": "people", "document_id": document_id}]},
)
assert resp.status_code == 200
await asyncio.sleep(1.5)
# v2: original content preserved + a new paragraph. The unchanged first chunk
# forces the delta path (it needs ≥1 unchanged chunk), and the new chunk is
# extracted via the instrumented delta extraction call site.
v2 = v1 + "\n\nBob joined Google as a product manager in 2024. He previously worked at Meta."
resp = await trace_api_client.post(
f"/v1/default/banks/{bank_id}/memories",
json={"items": [{"content": v2, "context": "people", "document_id": document_id}]},
)
assert resp.status_code == 200
await asyncio.sleep(1.5)
# The document_id filter must now return both the full retain and the delta
# re-retain — i.e. ≥2 distinct retain trace_ids, every row tagged.
by_doc = (
await trace_api_client.get(
f"/v1/default/banks/{bank_id}/llm-requests",
params={"document_id": document_id, "operation": "retain"},
)
).json()
assert all(it["metadata"].get("document_id") == document_id for it in by_doc["items"])
retain_traces = {it["trace_id"] for it in by_doc["items"]}
assert len(retain_traces) >= 2, f"expected full + delta retain bound to document, got {len(retain_traces)}"
@pytest.mark.asyncio
async def test_memory_ids_mapped_to_retain_and_consolidation(trace_api_client, bank_id):
"""A retain trace maps to the facts it created; a consolidation trace maps to
the source memories it consumed (and any observations it produced). These are
attached to every row of the trace after the operation completes.
"""
await trace_api_client.put(f"/v1/default/banks/{bank_id}", json={"name": "Trace Bank"})
resp = await trace_api_client.post(
f"/v1/default/banks/{bank_id}/memories",
json={"items": [{"content": "Alice works at Google as a senior engineer.", "context": "people"}]},
)
assert resp.status_code == 200
# retain + the consolidation it triggers are fire-and-forget; give the trace
# writes and the post-operation memory_id UPDATE room under parallel load.
await asyncio.sleep(2.0)
data = (await trace_api_client.get(f"/v1/default/banks/{bank_id}/llm-requests")).json()
by_op = {item["operation"]: item for item in data["items"]}
# Retain maps to the created fact unit_ids (outputs).
retain = by_op["retain"]
created = retain["metadata"].get("memory_ids")
assert created, f"retain trace should map to created facts, metadata={retain['metadata']}"
# Consolidation maps to the source memories consumed (inputs). The retained
# fact is what gets consolidated, so it appears among the sources.
if "consolidation" in by_op:
sources = by_op["consolidation"]["metadata"].get("source_memory_ids")
assert sources, "consolidation trace should map to the source memories it consumed"
# Reverse lookup: ?memory_id=<created fact> returns every run touching it —
# the retain that produced it (memory_ids) and any consolidation that consumed
# it as a source (source_memory_ids).
by_mem = (
await trace_api_client.get(
f"/v1/default/banks/{bank_id}/llm-requests", params={"memory_id": created[0]}
)
).json()
assert by_mem["total"] >= 1
for it in by_mem["items"]:
meta = it["metadata"]
assert created[0] in (meta.get("memory_ids") or []) or created[0] in (meta.get("source_memory_ids") or [])
assert retain["trace_id"] in {it["trace_id"] for it in by_mem["items"]}, "producing retain trace must be returned"
@pytest.mark.asyncio
async def test_filter_by_status_and_operation(trace_api_client, bank_id):
await trace_api_client.put(f"/v1/default/banks/{bank_id}", json={"name": "Trace Bank"})
await trace_api_client.post(
f"/v1/default/banks/{bank_id}/memories",
json={"items": [{"content": "test content", "context": "test"}]},
)
await asyncio.sleep(1.0)
response = await trace_api_client.get(
f"/v1/default/banks/{bank_id}/llm-requests",
params={"status": "success", "operation": "retain"},
)
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
for item in data["items"]:
assert item["status"] == "success"
assert item["operation"] == "retain"
response = await trace_api_client.get(
f"/v1/default/banks/{bank_id}/llm-requests", params={"status": "error"}
)
assert response.json()["total"] == 0
@pytest.mark.asyncio
async def test_stats_endpoint_includes_tokens(trace_api_client, bank_id):
await trace_api_client.put(f"/v1/default/banks/{bank_id}", json={"name": "Trace Bank"})
await trace_api_client.post(
f"/v1/default/banks/{bank_id}/memories",
json={"items": [{"content": "stats content", "context": "test"}]},
)
await asyncio.sleep(1.0)
response = await trace_api_client.get(
f"/v1/default/banks/{bank_id}/llm-requests/stats", params={"period": "1d"}
)
assert response.status_code == 200
data = response.json()
assert data["trunc"] == "day"
assert len(data["buckets"]) >= 1
bucket = data["buckets"][0]
assert "statuses" in bucket
assert "tokens" in bucket
assert set(bucket["tokens"].keys()) == {"input", "output", "cached", "total"}
assert bucket["total"] >= 1
@pytest.mark.asyncio
async def test_disabled_writes_no_rows(memory):
memory._llm_recorder._enabled = False
app = create_app(memory, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
bid = f"llm_trace_disabled_{datetime.now().timestamp()}"
await client.put(f"/v1/default/banks/{bid}", json={"name": "No Trace"})
await client.post(
f"/v1/default/banks/{bid}/memories",
json={"items": [{"content": "nope", "context": "x"}]},
)
await asyncio.sleep(0.5)
response = await client.get(f"/v1/default/banks/{bid}/llm-requests")
assert response.status_code == 200
assert response.json()["total"] == 0
# ── real-LLM acceptance (provider matrix) ─────────────────────────────────────
@pytest.mark.hs_llm_mat
@pytest.mark.asyncio
async def test_real_llm_retain_and_consolidation_traced(memory_real_llm):
"""A real retain produces traced retain *and* consolidation calls with real
token usage. Runs across providers in the hs_llm_mat matrix to confirm the
GenAI record_llm_call path reports tokens for each provider."""
memory_real_llm._llm_recorder._enabled = True
memory_real_llm._llm_recorder._allowed_scopes = None # All scopes
app = create_app(memory_real_llm, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test", timeout=120.0) as client:
bank_id = f"llm_trace_real_{datetime.now().timestamp()}"
await client.put(f"/v1/default/banks/{bank_id}", json={"name": "Real Trace"})
resp = await client.post(
f"/v1/default/banks/{bank_id}/memories",
json={
"items": [
{
"content": "Alice is a data engineer from Turin who loves hiking in the Dolomites.",
"context": "profile",
}
]
},
)
assert resp.status_code == 200
# Real LLM latency + fire-and-forget writes: poll until both operations
# land (retain fact-extraction, then consolidation over the new facts).
by_op: dict[str, list[dict]] = {}
for _ in range(30):
await asyncio.sleep(1.0)
data = (await client.get(f"/v1/default/banks/{bank_id}/llm-requests?limit=50")).json()
by_op = {}
for item in data["items"]:
by_op.setdefault(item["operation"], []).append(item)
if "retain" in by_op and "consolidation" in by_op:
break
assert "retain" in by_op, f"expected a retain trace, got operations: {list(by_op)}"
assert "consolidation" in by_op, f"expected a consolidation trace, got operations: {list(by_op)}"
for operation in ("retain", "consolidation"):
entry = by_op[operation][0]
assert entry["status"] == "success", f"{operation} trace not successful: {entry}"
assert entry["provider"] == memory_real_llm._llm_config.provider
assert entry["input_tokens"] and entry["input_tokens"] > 0, f"no input tokens for {operation}"
assert entry["output_tokens"] and entry["output_tokens"] > 0, f"no output tokens for {operation}"
assert entry["total_tokens"] == entry["input_tokens"] + entry["output_tokens"]
assert entry["input"] is not None # the prompt messages were captured
# The operations map to the memory_units they touched: retain to the
# facts it created, consolidation to the source memories it consumed.
assert by_op["retain"][0]["metadata"].get("memory_ids"), "retain trace missing created memory_ids"
assert by_op["consolidation"][0]["metadata"].get("source_memory_ids"), (
"consolidation trace missing source_memory_ids"
)
@@ -81,7 +81,9 @@ def _make_lmstudio_llm() -> OpenAICompatibleLLM:
)
def _lmstudio_400_error(msg: str = "Tool choice of type 'function' is not supported. Use 'auto', 'none', or 'required'.") -> APIStatusError:
def _lmstudio_400_error(
msg: str = "Tool choice of type 'function' is not supported. Use 'auto', 'none', or 'required'.",
) -> APIStatusError:
"""Simulate the HTTP 400 LM Studio returns for unsupported tool_choice format."""
mock_response = MagicMock()
mock_response.status_code = 400
@@ -151,7 +153,11 @@ class TestLMStudioNamedToolChoiceBug:
assert result.tool_calls[0].name == "search_mental_models"
sent_kwargs = mock_create.call_args.kwargs
assert sent_kwargs["tool_choice"] == "required"
# The named dict is normalized to "required" + a single filtered tool,
# then "required" is downgraded to auto (omitted) because LM Studio
# silently drops it (#1563/#1179/#1877). The single filtered tool keeps
# the call forced in practice. See test_tool_choice_required_downgrade.py.
assert "tool_choice" not in sent_kwargs
assert len(sent_kwargs["tools"]) == 1
assert sent_kwargs["tools"][0]["function"]["name"] == "search_mental_models"
@@ -182,11 +188,12 @@ class TestLMStudioNamedToolChoiceBug:
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_lmstudio_string_tool_choice_works_fine(self):
async def test_lmstudio_string_required_is_downgraded(self):
"""
String tool_choice values ("auto", "none", "required") ARE supported by LM Studio.
Only the dict format {"type": "function", "function": {"name": "..."}} fails.
This test confirms the control case works.
LM Studio does not honour the string ``tool_choice="required"`` either:
it silently returns an empty tool_calls array (#1563/#1179/#1877). So
"required" is downgraded to auto (omitted) for LM Studio. See the
dedicated suite in test_tool_choice_required_downgrade.py.
"""
llm = _make_lmstudio_llm()
success_response = _make_tool_call_response("search_mental_models", {"query": "user name"})
@@ -197,16 +204,16 @@ class TestLMStudioNamedToolChoiceBug:
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "What is the user's name?"}],
tools=REFLECT_TOOLS,
tool_choice="required", # string form — LM Studio accepts this
tool_choice="required",
max_retries=0,
)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "search_mental_models"
# Confirm "required" was sent, not a dict
# "required" is omitted (downgraded to auto) rather than sent verbatim.
sent_kwargs = mock_create.call_args.kwargs
assert sent_kwargs["tool_choice"] == "required"
assert "tool_choice" not in sent_kwargs
class TestExpectedFixBehavior:
@@ -225,10 +232,11 @@ class TestExpectedFixBehavior:
"""
@pytest.mark.asyncio
async def test_fix_converts_named_tool_choice_to_required(self):
async def test_fix_converts_named_tool_choice_and_downgrades(self):
"""
After fix: named tool_choice dict is converted to "required" for lmstudio.
The API receives tool_choice="required" instead of the unsupported dict.
Named tool_choice dict normalized to "required" + a single filtered
tool "required" downgraded to auto (omitted) for lmstudio. The API
never sees the unsupported dict nor the silently-dropped "required".
"""
llm = _make_lmstudio_llm()
named_tool_choice = {"type": "function", "function": {"name": "search_mental_models"}}
@@ -248,11 +256,9 @@ class TestExpectedFixBehavior:
assert result.tool_calls[0].name == "search_mental_models"
sent_kwargs = mock_create.call_args.kwargs
# Fix: dict was converted to "required"
assert sent_kwargs["tool_choice"] == "required", (
f"Expected tool_choice='required', got {sent_kwargs['tool_choice']!r}"
)
# Fix: tools filtered to just the requested one
# Fix: dict was normalized then "required" downgraded to auto (omitted)
assert "tool_choice" not in sent_kwargs
# Fix: tools filtered to just the requested one (keeps the call forced)
assert len(sent_kwargs["tools"]) == 1
assert sent_kwargs["tools"][0]["function"]["name"] == "search_mental_models"
@@ -281,7 +287,9 @@ class TestExpectedFixBehavior:
)
sent_kwargs = mock_create.call_args.kwargs
assert sent_kwargs["tool_choice"] == "required"
# "required" is downgraded to auto (omitted) for lmstudio; the single
# filtered tool keeps the call forced.
assert "tool_choice" not in sent_kwargs
assert len(sent_kwargs["tools"]) == 1
assert sent_kwargs["tools"][0]["function"]["name"] == forced_tool_name
@@ -290,7 +298,9 @@ class TestExpectedFixBehavior:
"""
The fix is generalized: all providers convert named tool_choice to
"required" + filtered tools. OpenAI natively supports the dict format
too, so the behaviour is semantically identical either way.
too, so the behaviour is semantically identical either way. The real
OpenAI API (no base_url override) honours "required", so unlike the
self-hosted providers it is NOT downgraded.
"""
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
@@ -0,0 +1,130 @@
"""Regression tests for DataAccessOps.lock_document_for_write.
Covers vectorize-io/hindsight#1944: the retain document-ownership gate used a
single ``INSERT ... ON CONFLICT DO UPDATE ... RETURNING`` upsert. PostgreSQL
runs it as-is, but the Oracle adapter rewrites ``ON CONFLICT DO UPDATE`` to a
``MERGE``, which cannot carry a ``RETURNING`` clause so every retain 500'd
with ``DPY-1003: the executed statement does not return rows``.
The lock-and-read step now lives behind ``ops.lock_document_for_write`` so each
backend implements it natively (PG: one upsert; Oracle: idempotent insert +
``SELECT ... FOR UPDATE``). These tests pin the contract on PG (run in the
default suite) and the Oracle rewrite limitation that motivated the split.
"""
from datetime import datetime, timezone
import pytest
from hindsight_api.engine.memory_engine import fq_table
def _ts() -> float:
return datetime.now(timezone.utc).timestamp()
async def _seed_bank(conn, bank_id: str) -> None:
await conn.execute(
"INSERT INTO banks (bank_id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING",
bank_id,
bank_id,
)
@pytest.mark.asyncio
async def test_lock_document_for_write_returns_pending_then_existing_hash(memory):
"""Fresh row → '__pending__'; existing row → its stored hash."""
bank_id = f"test_lock_doc_{_ts()}"
doc_id = "doc-lock-regression"
documents = fq_table("documents")
backend = await memory._get_backend()
ops = backend.ops
async with backend.acquire() as conn:
await _seed_bank(conn, bank_id)
# First call creates the row and reports the placeholder hash.
async with conn.transaction():
first = await ops.lock_document_for_write(conn, documents, doc_id, bank_id)
assert first == "__pending__"
# Promote the placeholder to a real content hash, as the real retain
# flow does immediately after taking the lock.
await conn.execute(
f"UPDATE {documents} SET content_hash = $1 WHERE id = $2 AND bank_id = $3",
"real-hash-v1",
doc_id,
bank_id,
)
# A subsequent writer sees the committed hash (and re-takes the lock).
async with conn.transaction():
second = await ops.lock_document_for_write(conn, documents, doc_id, bank_id)
assert second == "real-hash-v1"
@pytest.mark.asyncio
async def test_lock_document_for_write_isolates_by_bank(memory):
"""The lock/read is scoped to (id, bank_id) — same doc_id in another bank
is a distinct row and starts at '__pending__'."""
suffix = _ts()
bank_a = f"test_lock_doc_a_{suffix}"
bank_b = f"test_lock_doc_b_{suffix}"
doc_id = "shared-doc-id"
documents = fq_table("documents")
backend = await memory._get_backend()
ops = backend.ops
async with backend.acquire() as conn:
await _seed_bank(conn, bank_a)
await _seed_bank(conn, bank_b)
async with conn.transaction():
assert await ops.lock_document_for_write(conn, documents, doc_id, bank_a) == "__pending__"
await conn.execute(
f"UPDATE {documents} SET content_hash = $1 WHERE id = $2 AND bank_id = $3",
"bank-a-hash",
doc_id,
bank_a,
)
async with conn.transaction():
assert await ops.lock_document_for_write(conn, documents, doc_id, bank_b) == "__pending__"
def test_oracle_merge_rewrite_cannot_carry_returning():
"""Root cause of #1944: PG's single-statement upsert-and-lock rewrites to an
Oracle MERGE, and MERGE can't RETURNING — so a ``fetchval`` on it gets no
rows back (DPY-1003). The dialect split in lock_document_for_write exists to
avoid emitting this form on Oracle."""
from hindsight_api.engine.db.oracle import _rewrite_pg_to_oracle
query, ignore_dup, returning_cols = _rewrite_pg_to_oracle(
"INSERT INTO documents (id, bank_id, original_text, content_hash) "
"VALUES ($1, $2, '', '__pending__') "
"ON CONFLICT (id, bank_id) DO UPDATE SET content_hash = documents.content_hash "
"RETURNING content_hash"
)
assert query.lstrip().upper().startswith("MERGE")
# The RETURNING clause is silently dropped by the MERGE rewrite — this is
# exactly why the old single-statement form returned no rows on Oracle.
assert "RETURNING" not in query.upper()
assert returning_cols is None
assert not ignore_dup
def test_oracle_select_for_update_hash_translates_cleanly():
"""The Oracle fallback reads the hash with a plain SELECT ... FOR UPDATE,
which translates without a MERGE so the scalar fetch returns the column."""
from hindsight_api.engine.db.oracle import _rewrite_pg_to_oracle
query, ignore_dup, returning_cols = _rewrite_pg_to_oracle(
"SELECT content_hash FROM documents WHERE id = $1 AND bank_id = $2 FOR UPDATE"
)
assert "MERGE" not in query.upper()
assert "FOR UPDATE" in query.upper()
assert ":1" in query and ":2" in query
assert not ignore_dup
assert returning_cols is None

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