Compare commits

...
Author SHA1 Message Date
Ben ff7a087a37 release(copilot-cli): v0.1.0 2026-07-28 09:55:30 -04:00
Scott Guymer 6500944c74 feat(copilot-cli): add GitHub Copilot CLI hooks integration (#2742)
* feat(copilot-cli): add GitHub Copilot CLI hooks integration

Add hindsight-integrations/copilot-cli/, giving GitHub Copilot CLI
persistent long-term memory via Hindsight hooks (see docs.github.com/en/
copilot/how-tos/copilot-cli/customize-copilot/use-hooks). Modeled on the
existing cursor-cli integration.

Hooks:
- sessionStart: recall using initialPrompt (or a cwd-derived fallback
  query), injects additionalContext
- subagentStart: recall for every subagent Copilot CLI spawns (explore,
  task, research, code-review, rubber-duck, security-review, and custom
  agents, not the built-in general-purpose agent, which never fires
  this hook). Subagent payloads carry no per-invocation task text, so
  this always uses the fallback query.
- agentStop: reads the transcript, retains to Hindsight on a configurable
  turn cadence, caches the transcript path for sessionEnd
- sessionEnd: forces a final retain using the transcript path cached from
  the last agentStop, since sessionEnd's own payload has no transcript
  path field

Install via pip install hindsight-copilot-cli, then hindsight-copilot-cli
install (user scope, writes ~/.copilot/hooks/hindsight-copilot-cli.json)
or --scope repo for a team-shared .github/hooks/ registration. Zero
runtime dependencies, hook scripts are pure stdlib Python.

Also wires up CI (test-copilot-cli-integration job), release-integration.sh
and generate_changelog.py registration, and docs gallery/sidebar entry.

Closes #1588

* fix(copilot-cli): regen skill mirror, drop unreleased changelog link

- Run generate-docs-skill.sh to add the missing skill mirror for the
  new copilot-cli doc page (verify-generated-files was failing on the
  untracked references/sdks/integrations/copilot-cli.md).
- Remove the [View Changelog] link, which pointed at
  /changelog/integrations/copilot-cli — a page the release script only
  creates on first release, so it was a broken link failing build-docs.
2026-07-28 09:52:59 -04:00
Nicolò Boschi 8133c5ab7e fix(worker): stop wedged retains from holding worker slots forever (#3020)
* fix(worker): stop wedged retains from holding worker slots forever (#3002)

A retain task that blocks indefinitely held its worker slot until the
process restarted. The operation stayed 'processing' — which the API
refuses to either retry or cancel — so once every slot was held the
worker stopped claiming retains and the backlog grew without bound.

Five changes, outermost first:

* HINDSIGHT_API_RETAIN_WALL_TIMEOUT (default 1h, 0 disables) bounds one
  retain task in the poller, mirroring REFLECT_WALL_TIMEOUT. The
  existing timeouts each bound one LLM call, query or acquire; none
  bounded the task. On expiry the executor is cancelled and the
  operation is marked 'failed', so it is retryable. asyncio.timeout()
  (not wait_for) so an inner TimeoutError isn't misreported as a wedge.

* The streaming retain pipeline now cancels both halves explicitly.
  Plain gather() propagated the consumer's exception but left the
  producer and every extraction task under it running; they parked
  forever on chunk_queue.put() into a queue nobody drained, pinning
  chunk payloads and still spending LLM permits on a failed operation.

* The LLM stage breadcrumb says '.queued' until the concurrency permits
  are held. It was stamped before the acquire, so a call waiting on a
  saturated semaphore was indistinguishable from one the provider was
  running — the label sent the reporting operator after Bedrock for
  tasks that had never reached Bedrock. Providers now stamp attempt 1
  too, so a retry ladder is visible from the first attempt.

* bulk_insert_entities orders by LOWER(name), making the database's
  collation the single arbiter of insert order for all writers. The
  caller already sorted by Python's str.lower(), which agrees with the
  conflict target for ASCII but not every locale.

* HINDSIGHT_API_DB_ACQUIRE_TIMEOUT now bounds the wait it names. It was
  only passed to create_pool(timeout=...), a connect kwarg; Pool.acquire()
  kept asyncpg's default of waiting forever, so pool exhaustion never
  surfaced as an error.

* docs: regenerate hindsight-docs skill reference for RETAIN_WALL_TIMEOUT
2026-07-28 14:40:42 +02:00
Merlin_r68 9a1ba951fa fix(llm): send reasoning_effort on the tool path, matching call() (#2983)
`OpenAICompatibleLLM` builds request params in two places. `call()` sets
`reasoning_effort` for reasoning models; `call_with_tools()` built its own
`call_params` and never did.

Omitting it is not a neutral default. Measured against the OpenAI API for
gpt-5.6-terra with function tools:

    reasoning_effort="low"   -> HTTP 400
    reasoning_effort absent  -> HTTP 400
    reasoning_effort="none"  -> succeeds

    "Function tools with reasoning_effort are not supported for
     gpt-5.6-terra in /v1/chat/completions. To use function tools, use
     /v1/responses or set reasoning_effort to 'none'."

So `HINDSIGHT_API_LLM_REASONING_EFFORT=none` could not fix it — that setting
only ever reached `call()`. Reflect is a tool-calling search loop, so every
tool call 400'd, retried, and fell back to a tool-less completion. The
fallback still returned content and still stamped `last_refreshed_at` and
cleared `is_stale`, so mental models looked refreshed while never having
searched memory. The only outward signal was input-token volume: ~800 per
call degraded, versus 2.4k-8.2k healthy.

The fix mirrors `call()` rather than gating by provider: `call()` already
sends this parameter to the same provider/model pairs under the same
capability check, so gating the tool path by provider would replace one
asymmetry with another. A parameterized test pins that contract.

Not addressed here, to keep the change reviewable — `call_with_tools()` also
diverges from `call()` by applying temperature unconditionally (reasoning
models generally reject it) and by omitting groq's `service_tier` and
`include_reasoning`. Neither has a reproduction; both deserve their own change.

Verified: 7 new tests; deleting the hunk fails 6 of them; 161 provider tests
pass. Live end-to-end, reflect went from 20 errors and an 806-token fallback
to zero errors and 2.4k-7.5k-token real searches, refreshing 5 mental models
in 91s.
2026-07-28 14:31:29 +02:00
Sanderhoff-alt 20caf8aa5c refactor(retain): require explicit semantic link thresholds (#3004)
Require semantic-link thresholds to be passed explicitly to the
low-level ANN, within-batch, and batch-creation helpers.

Make the streaming final-ANN threshold keyword-only to prevent
positional argument mistakes, and rename the forwarding test to match
what it verifies.
2026-07-28 14:24:49 +02:00
Nicolò Boschi ac4df7eb8e fix(operations): re-runnable batch_retain parents (retry re-queues children) (#3018)
#2985 added a guard that rejected retry for every payload-null batch_retain
parent. But `retain --async` ALWAYS returns such a parent (submit_async_retain
creates a payload-less aggregator, even for a single item), so that guard made
async-retain operations un-retryable to users and 409'd the operations.sh doc
example — turning test-doc-examples(cli) red on main.

Make retrying a batch_retain parent re-run the batch's outstanding work instead
of rejecting it:
- re-queue the parent's failed/cancelled children to 'pending';
- revive the parent to 'pending' so it re-aggregates, but ONLY when at least one
  non-completed child remains to drive the reconcile — otherwise it would strand
  'pending' with nothing to promote it (the exact #2985 bug);
- leave pending/processing children untouched: a live worker owns a 'processing'
  child and resetting it would let a second worker race it on the same
  document_id (#1795);
- if there is nothing retryable (no children, or all completed), keep the 409 and
  point the caller at resubmit + delete.

This restores the natural "retry my async retain" UX and fixes the doc example
with no change to operations.sh.

Tests (deterministic, direct async_operations rows):
- failed child -> re-queued + parent revived;
- processing child -> untouched, parent revived;
- all children completed -> 409, parent NOT revived (no re-strand).
Updated test_retry_rejects_batch_retain_parent's docstring: it now covers the
childless case specifically.
2026-07-28 14:20:23 +02:00
Nicolò Boschi af196287e4 fix(transfer): preserve consolidation lifecycle on whole-bank import (#2965) (#3017)
Whole-bank export/import dropped each fact's consolidation lifecycle
(created_at, consolidated_at, consolidation_failed_at). Import rebuilt
consolidation state only from surviving observation lineage, so facts that
were consolidated (or failed) in the source but no longer back a surviving
observation lost their state and became re-eligible. The maintenance
reconciler then treated them as backlog and re-consolidated, duplicating
observations — violating the whole-bank contract of restoring exact state
without re-running consolidation.

- schema: TransferFact carries the three lifecycle timestamps (optional;
  absent in pre-fix archives -> None -> legacy fallback path).
- export: carry lifecycle exactly when observations are carried
  (always for export_bank; export_documents only with include_observations).
  The plain document export still omits them so it re-consolidates from
  scratch, which is correct there (it carries no observations).
- import: restore timestamps verbatim after fact insert; the
  observation-source marking now COALESCEs so it no longer clobbers a
  restored consolidated_at (still covers legacy archives).
- test: regression covering consolidated-but-observationless facts, a
  failed fact, exact lifecycle equality, zero reconciler backlog, and
  unchanged observation count.
2026-07-28 14:19:24 +02:00
Nicolò Boschi 678ca0e908 fix(reflect): fail on unusable tool calls instead of salvaging leaked text (#3013)
* fix(reflect): fail on unusable tool calls instead of salvaging leaked text

Reflect is driven by structured tool calls. Some provider transports don't
actually support function calling and silently strip the tool definitions from
the request (e.g. litellm's Vertex AI gpt-oss MaaS path drops tools/tool_choice
when the model is flagged unsupported). The model then answers in free text that
mimics a done() payload, which landed in message.content with empty tool_calls.
The old code served that raw text as the answer, so a growing pile of regex/JSON
"strippers" tried to claw the leaked memory_ids/observation_ids/directive_compliance
siblings back out of the user-facing answer.

Instead of salvaging untooled text, fail loudly:

- Track whether the model ever produced a tool call reflect could parse. If it
  never does (the stripped-tools case), raise ReflectToolCallError -> HTTP 500
  (the request is valid; the server's configured model can't do the job) with a
  clear message (provider, model, response snippet).
- Keep the done tool; _process_done_tool now trusts args["answer"] verbatim.
  A parsed tool call can't bleed its sibling id fields into the answer string.
- A model that DID tool-call and later stops with text is a legitimate stop and
  still routes through the clean forced-final synthesis path.
- Delete the entire strip zoo: _clean_done_answer, _unwrap_leaked_done_arguments,
  _strip_trailing_id_json_object, _clean_answer_text, _DONE_CALL_PATTERN, and the
  leaked-JSON regexes/key-sets. The forced-final paths return the model's prose
  directly (tools are disabled there, so there is no tool syntax to strip).

No static supports_function_calling gate -- reflect just tries and fails.

Supersedes the answer-salvage approach in #2972.

* test(mock): drive the reflect loop via tool calls, not bare prose

The reflect agent now rejects a turn that yields no usable tool call
(ReflectToolCallError). MockLLM's default call_with_tools returned bare
"mock response" content with no tool calls, which the old salvage path served
as the answer -- so ~15 reflect integration tests (empty-bank, tracing,
based_on, tags, think) started failing with 500 under the new guard.

Make MockLLM simulate a compliant tool-calling provider in its default path:
honor a forced retrieval tool_choice (so recall/search actually run and populate
based_on), and otherwise finish via the done tool. Tests that script their own
turns via _response_callback / _mock_response are unaffected.
2026-07-28 13:55:09 +02:00
Nicolò Boschi 6fe0dd690f fix(oracle): audit_log write qualification + llm_requests read gating (#3015)
Two remaining Oracle issues in the observability tables, both surfaced as
ORA-error spam in the Oracle CI logs (follow-up to the llm_requests write gate):

1. Audit writes (audit.py). `AuditLogger._safe_log` built `f"{schema}.audit_log"`,
   which on Oracle is `public.audit_log` — "public" is a reserved word there, so
   every write failed with ORA-00903 even though the table DOES exist on Oracle.
   Fix: use `fq_table_explicit("audit_log", schema)`, which qualifies per dialect
   ("schema".audit_log on PostgreSQL, bare audit_log on Oracle where the schema is
   set at the session level). This makes audit writes actually work on Oracle.

2. llm_requests reads (memory_engine.py). Unlike audit_log, `llm_requests` is
   PostgreSQL-only (its migration omits the Oracle slot; LLMTraceRecorder already
   skips writes on Oracle). `list_llm_requests` and `llm_request_stats` still ran
   `SELECT ... FROM llm_requests`, which is ORA-00942 on Oracle. Fix: after the
   bank-auth check (so a missing bank still 404s), return an empty page / empty
   stats on Oracle instead of querying a non-existent table.

Tests:
- test_audit_per_bank: capture the emitted SQL via a fake pool and assert the
  audit INSERT targets bare `audit_log` on Oracle (no `public.`) and `"schema".
  audit_log` on PostgreSQL.
- test_llm_trace: the list and stats endpoints return empty (200, not 500) when
  the backend is Oracle. Both deterministic, run on the default PG backend.
2026-07-28 12:36:32 +02:00
Nicolò Boschi 2620a2a3fa fix(embeddings,reranker): default local models to CPU on Apple Silicon (MPS memory leak) (#2988)
* fix(embeddings,reranker): default local models to CPU on Apple Silicon (MPS memory leak)

Local embedding + reranker inference on the PyTorch MPS (Metal) backend caches a
distinct compiled kernel graph and allocator pool per unique input tensor shape
and never releases it. Under the engine's variable-length, high-volume
recall/rerank/embed traffic (documents and candidate sets of every size), that
per-shape cache grows without bound: a local API instance was observed idling at
~20 GB (phys_footprint) — ~9.4 GB of Metal graphics memory plus ~8 GB of native
heap, essentially all of it stale per-shape MPS cache. CPU inference has no such
per-shape cache: the same workload holds flat at a few hundred MB, with
negligible latency cost for the small default models (and MPS actually slows down
over time as it recompiles graphs for new shapes).

Fix:
- MPS is now opt-in. select_local_device() (new engine/local_device.py) picks CPU
  when the only accelerator is Apple Silicon MPS; CUDA/XPU still auto-select. Set
  HINDSIGHT_API_{EMBEDDINGS,RERANKER}_LOCAL_ALLOW_MPS=true to opt back in.
- Post-batch memory release is consolidated in local_device.py and now also runs
  on macOS: it returns freed native pages to the OS (glibc malloc_trim on Linux,
  malloc_zone_pressure_relief on macOS — the #1717 fix previously covered only
  Linux) and empties the GPU allocator pool (torch.<backend>.empty_cache) when a
  GPU was used. The release path is wired into the embeddings encode path too,
  which previously released nothing.

Validated end-to-end through the real LocalSTEmbeddings/LocalSTCrossEncoder
classes under 150 iterations of variable-length load: default config runs on CPU
and holds flat at ~420–455 MB (vs. MPS climbing past 7.8 GB toward the observed
20 GB); the ALLOW_MPS opt-in still reaches the MPS device.

* docs(local_device): link the upstream PyTorch MPS graph-cache issues we track

* fix: only release GPU cache after local embedding when on a GPU; regen docs skill

Two CI fixes:
- embeddings.encode() ran gc.collect() + heap-trim on every call. encode() is on
  the retain hot path (a batch retain calls it many times), so a full gc.collect()
  per call added enough overhead to time out heavy retain tests
  (test_large_batch_auto_chunks). Guard the release to GPU devices only: on the CPU
  default there is nothing to reclaim that refcounting doesn't already free, and
  the opt-in MPS/CUDA path still gets empty_cache(). The reranker keeps its
  per-batch heap trim (#1717, lighter recall path).
- Regenerated skills/hindsight-docs/references/developer/configuration.md from the
  docs source (generate-docs-skill.sh) so verify-generated-files passes.
2026-07-28 11:29:31 +02:00
Nicolò Boschi ca755f8ca2 fix(oracle): skip LLM trace writes on Oracle (llm_requests is PG-only) (#3012)
`LLMTraceRecorder` wrote every LLM call into `llm_requests`, but that table is
PostgreSQL-only — its migration is `run_for_dialect(pg=...)` with the Oracle
slot intentionally absent, and `MaintenanceLoop.start` already skips its
retention sweep on Oracle for the same reason. The write path missed that gate,
so on Oracle every LLM call fired an INSERT that failed with:

    ORA-00903: invalid table name        (INSERT INTO public.llm_requests ...)

("public" is a reserved word on Oracle, so the schema-qualified name fails to
parse; and the table does not exist there regardless.) The failures are caught
and logged, so nothing breaks functionally, but they spam the error log on every
retain/consolidation call — visible throughout the Oracle CI logs.

Gate the recorder on the backend, mirroring MaintenanceLoop: a new
`_llm_requests_persistable()` returns False on Oracle, and both write entry
points (`is_enabled`, consulted by `record_llm_call`, and `attach_memory_ids`)
short-circuit before scheduling any work. PostgreSQL behaviour is unchanged.

Note: `audit_log` DOES exist on Oracle but `AuditLogger._safe_log` builds the
same `f"{schema}.audit_log"` (→ `public.audit_log`, also ORA-00903). That is a
distinct bug (wrong qualification, not a missing table) and audit is off by
default so it wasn't in the failing logs — left for a separate change.

Test: test_recorder_disabled_on_oracle_backend forces the Oracle backend and
asserts the recorder reports disabled and records nothing (deterministic, no
live Oracle needed).
2026-07-28 11:08:07 +02:00
Nicolò Boschi 8f19087c2b fix(claude-code): make reflect tool calls work and honor configured model (#2980)
Two fixes to the claude-code provider's ClaudeAgentOptions blocks.

#2966 — reflect agent made 0 tool calls. call_with_tools() is one *round*
of a loop the caller drives (reflect/agent.py executes the real tools and
feeds results back), but the SDK ran its own in-process loop against our
placeholder MCP handlers. With max_turns=2 the model called recall, saw the
empty placeholder, re-queried, exhausted the budget → error_max_turns → and
the code raised on that, discarding the tool calls it had made (trace then
read tools=[none]). Fix: cap the SDK at max_turns=1, break out of the stream
after the first proposed tool call, and treat the trailing error_max_turns as
non-fatal when tool calls were already captured. This matches every other
provider's single-round call_with_tools semantics.

#2881 — the configured model never reached the CLI: neither options block
passed model=, so every call ran the CLI's own default (Opus-class on Pro/Max
OAuth) while metrics/logs still printed self.model. The isolated
CLAUDE_CONFIG_DIR means a host settings.json can't reach the CLI either, so
model= is the only channel. Fix: pass model=self.model in both call() and
call_with_tools().

Tests: new test_claude_code_llm_tool_round.py (fake-SDK: tool call returned
despite error_max_turns, stops after first round, text-only answer, model
pinned on both paths, genuine error still raised). Both fixes verified
end-to-end against the real SDK.
2026-07-28 10:52:37 +02:00
Nicolò Boschi 4708a3661b fix(worker): reconcile stranded batch_retain parents on recovery (#2985) (#2986)
A batch_retain parent is a payload-less status aggregator: workers never
claim it, and it is promoted to a terminal state only when its last child
sub-batch finishes (_maybe_update_parent_operation). Two crash windows
strand it 'pending' forever — the aggregation swallowing a transient error
after all children are terminal, or children that never committed. Such a
parent is unclaimable, invisible to failed_operations, unretryable via the
API, and its documents are silently absent.

- Add WorkerPoller._reconcile_orphaned_parents(), run at the end of the
  per-schema recover_own_tasks() pass. Pending payload-null batch_retain
  parents are driven terminal: all-terminal children -> completed/failed
  (inheriting a representative child error), no children -> failed with an
  explicit resubmit hint. Parents with a live child are left to normal
  aggregation.
- Guard retry_operation so a batch_retain parent (null payload) cannot be
  retried into a re-stranded 'pending' state; the 409 points at the
  supported recovery (resubmit + delete).

Tests: reconciliation coverage in test_worker.py and a retry-guard test in
test_operation_status.py.
2026-07-28 10:29:53 +02:00
Ben e57765e012 feat(zapier): remove memoryDefenseTriggered trigger (gated capability) (#2994)
* feat(zapier): remove memoryDefenseTriggered trigger (gated capability)

Memory Defense is a gated capability: enabling it returns 400
'detectors_not_entitled' for orgs without the sensitive_data detector, so a
public Zapier trigger for memory_defense.triggered can never satisfy Zapier's
T001/S002 'one live run' review checks for un-entitled users.

- Remove the trigger from index.js and delete triggers/memoryDefenseTriggered.js
- Add guard tests asserting the exposed trigger set and that the trigger is absent
- Drop it from the package README and the Zapier integration docs page
- retain.completed and consolidation.completed remain (verified delivering on Cloud)

* chore(zapier): prettier-format triggers.test.js
2026-07-27 16:46:59 -04:00
Ben 2d0cd46084 blog: What people actually build with agent memory (use cases) (#2990)
* blog: What people actually build with agent memory (use cases)

Overview post walking through the concrete patterns teams build on
Hindsight: coding agents, per-user products, support/account assistants,
voice, chat platforms, self-built framework agents, multi-agent shared
banks, and automations. One primitive (retain/recall/reflect over a
bank), scoped and surfaced differently.
2026-07-27 14:44:07 -04:00
Ben 3feb111c86 docs(zapier): clarify private-beta availability + Webhooks-by-Zapier path (#2989)
* docs(zapier): clarify private-beta availability + Webhooks-by-Zapier path

* docs(zapier): remove Option B (private-beta native app), keep Webhooks path
2026-07-27 13:43:48 -04:00
Nicolò Boschi 581b7c48cc fix(oracle): don't COALESCE a bind against the CLOB mission column in update_bank (#2981)
`update_bank` wrote `SET mission = COALESCE($3, mission)`. On Oracle `mission`
is a CLOB, and COALESCE derives its result type from the first argument — the
bind `$3`, which oracledb sends as a VARCHAR2. Oracle then evaluates the CLOB
`mission` in a "CHAR expected" context and raises:

    ORA-00932: expression ("BANKS"."MISSION") is of data type CLOB,
               which is incompatible with expected data type CHAR

This broke every createBank/update that set a mission on Oracle — the failure
behind the persistently-red test-typescript-client-oracle job (`createBank`
issues a name+mission update).

Fix: build the UPDATE's SET clause from only the columns actually supplied and
assign them directly (`SET mission = $n`), the way set_bank_mission already
writes the CLOB. Assigning a string straight into a CLOB is fine on Oracle; it's
the cross-type COALESCE that fails. Untouched columns are simply not written,
which is the same result the COALESCE-of-NULL produced. Behaviour on PostgreSQL
is unchanged.

Tests:
- test_http_api_integration: new deterministic PG regression asserting name and
  mission round-trip, plus a mission-only update (runs on every CI shard).
- test_oracle_integration: test_bank_profile_crud now asserts the mission value
  round-trips (it already exercised this path but only checked name; the Oracle
  suite is skipped in normal PR CI, so the live coverage was the TS client job).
2026-07-27 17:37:36 +02:00
Nicolò Boschi ed248447e2 fix(control-plane): gate audit-log & observations tabs on resolved per-bank config (#2982)
The audit-logs and observations tabs gated on features.audit_log /
features.observations from the /version endpoint, which only reports the
global (server-level) default. Both fields are hierarchical
(env -> tenant -> bank), so a bank that opts in via per-bank config still
saw "not enabled" because the global flag stays off.

Gate these tabs on the bank's resolved config (getBankConfig) instead,
falling back to the global flag when the bank config API is disabled
(per-bank overrides can't exist then) or the field is unavailable.
2026-07-27 16:00:14 +02:00
Nicolò Boschi 5792b2b864 fix: avoid dotenv side effects on library import (#2979)
* fix: avoid dotenv side effects on library import (#2961)

`hindsight_api.config` called `load_dotenv(find_dotenv(usecwd=True),
override=True)` at module scope. Importing `hindsight_api` (or anything that
pulls it in — `import hindsight`, `HindsightEmbedded`) therefore walked up from
the host process cwd and overwrote the embedding application's own environment,
with override=True beating values it had set deliberately (#2961).

Move the load out of module scope into a `load_dotenv_for_entrypoint()` helper
that Hindsight's standalone entry points call explicitly: the API CLI
(`main.py`), the ASGI app (`server.py`), the worker, and the admin CLI. Library
imports are now side-effect-free.

Backwards compatibility for our own deployments is preserved exactly:
- `override=True` is kept in the helper, so a discovered `.env` stays
  authoritative over the ambient process env — unchanged precedence.
- `server.py` is covered, not just the CLI: it is the `uvicorn
  hindsight_api.server:app` target AND the import string uvicorn re-imports in
  each worker process when `hindsight-api` runs with `--workers`/`--reload`, so
  omitting it would silently break `.env` loading in multi-worker mode.
- `tests/conftest.py` now loads the workspace `.env` with `override=True`,
  matching the precedence config.py used to apply at import time (the oracle
  fixture depends on `.env` being authoritative).

Also drop the now-obsolete `_EARLY_DB_URL` workaround in `recall_perf.py`.

Closes #2961

* style: ruff-format test_fact_extraction_retry signature (pre-existing #2969 drift)

`ruff format` collapses this test's parametrized signature onto one line (it
fits within the 120-char limit). #2969 (e5cd23940) committed the multi-line form,
so verify-generated-files now flags it on every new branch. Not related to the
dotenv change — folded in here only to keep the whole-tree generated-files check
green.
2026-07-27 14:36:35 +02:00
EvoandNicolò Boschi e5b4c52d7e fix(clients): expose async retain operation_id (#2978)
* fix(clients): expose retain operation_id

* fix(clients): warn when operation_id is dropped on sync retain

operation_id only enables idempotent retries for asynchronous retain; on a
synchronous request it was silently dropped. Emit a warning at each retain
entry point (Python warnings.warn / TS console.warn) so a caller who forgets
retain_async=True learns their idempotency key was ignored.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-27 12:19:48 +02:00
Tommaso Fontana b475f5cca0 perf: eliminate redundant graph seed and UUID scans (#2968)
* perf(recall): reuse semantic candidates for graph seeds

* perf(retain): preserve UUID index for date lookup

* test: expand PostgreSQL optimization coverage
2026-07-27 12:12:30 +02:00
dimonnld 4724f26d33 Add Russian temporal period rules (#2767)
Extend the non-Chinese period table with Russian relative expressions
(вчера/позавчера/сегодня, «пару|несколько дней|недель|месяцев назад»,
прошлой неделе|месяце|году, прошлых выходных) and Russian month names in
their inflected forms, so Russian time queries get the same deterministic
extraction as English.

Russian months inflect: dateparser only resolves the nominative ("май"),
while "в мае" (prepositional) and "мая" (genitive, in explicit dates) are
the forms that occur. Enumerated per month with word-boundary guards so
stems inside longer words (майонез, мартовские) must not match.
2026-07-27 12:10:50 +02:00
Evoandr266-tech c908fade19 fix(consolidation): stop emitting unsupported maxItems that breaks all Bedrock consolidation (#2500) (#2502)
* fix(consolidation): stop emitting unsupported maxItems that breaks all Bedrock consolidation (#2500)

_build_response_model attached a Pydantic max_length to creates, which serializes to JSON-schema maxItems; Bedrock Converse rejects maxItems on array types, failing 100% of consolidation for capped Bedrock banks. The cap is already enforced by the prompt capacity note + unconditional truncation to remaining_observation_slots, so the schema constraint is dropped.

* test(consolidation): assert response schema omits maxItems (#2500 regression)

Rewrite TestBuildResponseModel to the new contract: factory always returns the base model, schema omits maxItems (Bedrock-compatible), over-cap creates are accepted (truncated downstream) rather than rejected. End-to-end cap enforcement remains covered by the existing max_observations_per_scope integration tests.

* Add an opt-out for maxItems schemas

---------

Co-authored-by: r266-tech <[email protected]>
2026-07-27 12:10:19 +02:00
Evoandr266-tech c65bf5c9eb fix(control-plane): preserve observations inheritance (#2885)
Co-authored-by: r266-tech <[email protected]>
2026-07-27 12:09:59 +02:00
Ben 2acd66df44 docs(openclaw): note memory-wiki bridge mode is unsupported (#963) (#2955) 2026-07-27 12:09:26 +02:00
Nick Old f7ff5341f7 fix: return free-form entities from dry-run extraction (#2958) 2026-07-27 12:09:09 +02:00
Jevinandijevin dcd3ba57e4 test(litellm): cover Responses named tool choice (#2953) (#2957)
Co-authored-by: ijevin <[email protected]>
2026-07-27 12:08:47 +02:00
Jay Stothard e5cd239401 fix: accept text alias in fact extraction (#2969) 2026-07-27 12:04:37 +02:00
Evo 1fa2de3327 Reject misplaced file retain metadata (#2971) 2026-07-27 12:03:57 +02:00
Ben ed120a256d blog: recall vs reflect (the two ways to read agent memory) (#2954)
* blog: recall vs reflect (the two ways to read agent memory)

Feature/decision piece contrasting Hindsight's two read operations:
recall (hybrid retrieval + rerank, no LLM, ranked facts, sub-second)
vs reflect (agentic loop with an LLM, hierarchical retrieval, synthesized
answer, response_schema, validated cited sources). Includes comparison
table, decision guide, and FAQ. Grounded in the recall/reflect engine
and API docs. Cover: recall vs reflect contrast panels.

* blog: use Inside retain() editorial theme for recall vs reflect cover

* blog: fact-check fixes to recall section

Adversarial verification against the recall engine found three
inaccuracies: recall runs 3 retrieval strategies always (semantic, BM25,
graph) with temporal conditional (not 4); no MMR/diversity pass is
implemented (docstring only); high budget defaults to 1000 not 600.
Softened 'local cross-encoder' since remote rerankers are configurable.
reflect claims all verified accurate.

* blog: fix API-doc link paths (/developer/api/... not /docs/...)
2026-07-24 14:20:20 -04:00
Sanderhoff-altandNicolò Boschi 73b575c7a3 fix(graph): queue edited and restored memories for relinking (#2893)
* fix(graph): queue edited and restored memories for relinking

Graph maintenance rebuilds outgoing temporal and semantic links only for
units explicitly present in its queue. Edits and restores submitted the
worker without queuing the affected unit, so its outgoing links could
remain missing.

Queue edited units together with incoming-link victims in one sorted
insert to preserve the global lock order. Queue restored units after
their searchable fields have been rebuilt.

Cover outgoing-only restore and bidirectional edit cases, including a
single queue write for the edited unit and its victims.

Fixes #2889.

* test(graph): cover the outgoing-only relink case; tidy enqueue helper

The PR's tests only exercised mutually linked units, so the branch the bug
actually lived in — an edited/reverted unit with outgoing links but no
incoming ones, where the victim lookup is empty — was untested.

Tests:
- enqueue_relink_victims: include_affected_units with no victims (returns
  the unit itself), with victims (one combined sorted insert), and the
  default opt-out for delete callers.
- Curation: an outgoing-only edit queues itself, plus two end-to-end tests
  that let the inline SyncTaskBackend drain the queue and assert the
  temporal link is actually rebuilt after an edit and after a revert.

All five fail on the pre-fix engine.

Tidy:
- Rename deleted_unit_ids -> affected_unit_ids; with the new flag the
  helper also takes units that stay live, so the old name/doc misled at
  the edit call site. Same for the debug log wording.
- Spell out at both call sites why the edit combines self+victims in one
  insert, why the invalidating edit opts out, and that revert rebuilds
  only the reverted unit's outgoing links.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-24 17:58:44 +02:00
Nicolò Boschi 6d5157575c fix(oracle): unblock Oracle CI — free runner disk space + fix the retain deadlock (#2948)
* ci(oracle): free runner disk space before Oracle jobs

The three Oracle jobs run the Oracle 23ai `free` service image, which
together with the Python ML deps (torch) exhausts the runner's ~14 GB root
disk. Two symptoms, one cause:

- uv fails to extract a wheel with "No space left on device (os error 28)"
  (fast ~2 min failure), and
- a near-full disk starves I/O badly enough to trip the 30-minute job
  timeout.

test-python-client-oracle and test-typescript-client-oracle have been red on
every open PR (#2941, #2942, #2943) from this, independent of the code under
test. Reclaim ~20 GB of preinstalled tooling (the same jlumbroso action the
Docker build job already uses) before the Oracle setup step.

docker-images stays false here: unlike the Docker build job, the Oracle
service container is already running by the time steps execute, so pruning
images could disrupt it. The savings come from the tool cache, Android SDK,
.NET, Haskell, large apt packages and swap.

* ci(oracle): trim disk reclaim to the fast, high-yield options

The first pass enabled every reclaim, which cost ~4 minutes of job time —
counterproductive on jobs that are already fighting a 30-minute limit.

android + dotnet + haskell + swap are a few rm -rf's worth ~16-21 GB, which
is ample headroom for the Oracle image plus torch. Dropped:
- large-packages: apt-get remove, costs minutes for little extra space;
- tool-cache: deletes the preinstalled Python that actions/setup-python then
  re-downloads, making the job slower rather than faster.

* fix(retain): flush entity stats after releasing the connection (Oracle hang)

Retain hung forever on the Oracle backend: every retain test burned its 120s
client timeout while the server sat idle, so test-python-client-oracle and
test-typescript-client-oracle only ever reached ~5% of the suite before the
30-minute job limit.

The server was not slow — it was deadlocked. flush_pending_stats() acquires
its own connection, but it was being called while the enclosing
acquire_with_retry(...) block still held one:

  async with acquire_with_retry(pool) as conn:   # conn checked out
      async with conn.transaction():             # SAVEPOINT only
          ...write facts/entities...
      await entity_resolver.flush_pending_stats()  # takes a 2nd connection

oracledb does not autocommit and OracleConnection.transaction() is only a
SAVEPOINT, so the write is committed by OracleBackend.acquire() when its block
exits. Connection #2's `UPDATE entities ...` therefore waits on row locks held
by the still-open connection #1, which cannot commit until the call returns —
a circular wait. Oracle never reports ORA-00060 because session #1 is blocked
in Python, not on the database, so it hangs indefinitely instead of erroring.

Move the flush after the acquire block in all three call sites (streaming
retain, delta retain, transfer importer), which is what its own docstring
already required ("must be called AFTER the retain transaction commits") and
which PostgreSQL satisfied only by accident via asyncpg autocommit.

Guarded with an AST lint test rather than a behavioural one: the deadlock
cannot be reproduced against PostgreSQL, which is what the suite runs on.

* test(repair): retry the concurrent index drop on deadlock

test_dry_run_creates_nothing still flaked in test-api shard 3. CONCURRENTLY
avoids ACCESS EXCLUSIVE but still takes ShareUpdateExclusive, which conflicts
with the ShareLock a fresh bank's plain CREATE INDEX holds — and that one
cannot be made concurrent, since it runs inside the bank-create transaction.
So _drop_bank_indexes can still be picked as the deadlock victim while another
xdist worker seeds a bank:

  Process A waits for ShareUpdateExclusiveLock on memory_units; blocked by B.
  Process B waits for ShareLock on virtual transaction; blocked by A.

The bank-create side already retries (#2943); give the drop the same treatment.
The drop is idempotent, so retrying is safe.
2026-07-24 17:31:38 +02:00
Ben 1a4388ae49 release(paperclip): v0.3.0 2026-07-24 11:13:42 -04:00
Eric OgdenandClaude Sonnet 5 0c6d54dc8a feat(paperclip): per-agent enable/disable for pilot rollouts (#2724)
Add optional enabledAgentIds config field to restrict Hindsight recall/retain to
a subset of agents. When set, only listed agent IDs trigger memory operations;
unset or empty array = unchanged behavior (all agents). Enables pilot rollouts on
high-signal agents before fleet-wide enable, reducing LLM cost/latency risk.

- Add enabledAgentIds: string[] to instanceConfigSchema (manifest.ts)
- Add isAgentEnabled() gate function to worker.ts
- Gate agent.run.started recall, agent.run.finished, and issue.comment.created
  retain handlers (the actual LLM-cost operations)
- Add 6 test cases covering allowlist pass/fail, empty array, and unset behavior
- Update README config table

Co-Authored-By: Claude Sonnet 5
2026-07-24 11:11:52 -04:00
Nicolò Boschi 370d930341 docs(consolidation): define every input field in the consolidation prompt (#2952)
The consolidation prompt serializes temporal metadata the INPUT section never
explained. `mentioned_at` in particular was emitted on new-fact lines, on each
existing observation, and on every embedded source memory, while the format
description documented only id/text/proof_count/occurred_start/occurred_end --
so the model received the timestamp with no idea what it meant or that it
represents how current a statement is.

Define each field the serializer actually emits, and note that `mentioned_at`
tracks when the source material was written rather than when it was ingested,
which is what makes it meaningful for out-of-order document ingestion.

The two copies of the format description (the cached bank-agnostic system
prefix and the single-message template) are now built from shared constants so
they cannot drift apart.

Refs #2550
2026-07-24 16:53:07 +02:00
Nicolò Boschi 0e5aa8896e fix(curation): keep causal links across edit and invalidate/restore (#2951)
Causal edges (`caused_by` plus the historical `causes`/`enables`/`prevents`)
are retain-time extraction output. Nothing recreates them: graph maintenance
only rebuilds temporal/semantic links and consolidation regenerates
observations, not raw-fact edges. Curation destroyed them anyway (#2864):

* every edit — including a context-only one — deleted all incident
  `memory_links` rows, and
* invalidation moves the row out of `memory_units`, so the FK cascade took
  its causal edges with it and restore had nothing to bring back.

Edits now delete only the derived link types, so a corrected fact keeps the
causality the extractor asserted for it (preserving the assertion is the
reversible choice; deleting it is not). Invalidation snapshots the incident
causal edges into a new `causal_links` JSONB column on the archive row, and
restore rematerializes the ones whose peer endpoint is live again.

The snapshot also picks up descriptors parked on archived peers that name the
unit, so an edge whose both endpoints are invalidated survives on both archive
rows and is recreated by whichever endpoint is restored last — restore order
doesn't matter. Rematerialization goes through the existing bulk-insert path,
which drops links whose endpoints aren't live and is `ON CONFLICT DO NOTHING`,
so repeated invalidate/restore cycles never duplicate an edge or resurrect one
pointing at a permanently deleted memory.
2026-07-24 16:38:06 +02:00
Sanderhoff-altandNicolò Boschi 0f47c7a8dc fix(auth): authorize bank writes before provisioning (#2646)
* fix(config): validate bank config updates before creating banks

Route external bank configuration writes through MemoryEngine so tenant
authentication and UPDATE_BANK_CONFIG authorization happen consistently.

Validate profile and configuration changes before creating a bank or
persisting either one. Rejected configuration updates through PUT,
PATCH, import, and MCP therefore leave no empty bank or partial profile
changes behind.

Keep memory-defense validation behavior unchanged, and cover the new
ordering and delegation paths with regression tests.

* fix(import): preflight template operations before creating banks

Preflight every template operation before creating a missing bank.
Reject duplicate mental models and directives before applying changes.

Reuse request-local authorization decisions while the import executes,
avoiding duplicate hook calls that may reserve quota or depend on time.
Precheck mental-model refresh availability so common failures do not
leave a newly created bank or a partially applied template behind.

Document that the authorization context creates the bank after all
checks pass.

* fix(mcp): create banks through public engine APIs

Delegate MCP bank creation to MemoryEngine's public profile and update
APIs instead of calling _ensure_bank_exists() directly.

Use get_bank_profile() for default creation and update_bank() when name
or mission fields are supplied. This keeps lifecycle validation and
authorization ordering inside the engine and avoids duplicate reads.

Add coverage for both public API paths and assert that MCP never invokes
the private creation helper.

* fix(config): fail loudly when persisting config for a missing bank

Bank creation moved out of ConfigResolver into MemoryEngine, but the
persist step still returned normally when the UPDATE matched zero rows.
A caller that skipped provisioning silently discarded its overrides
while reporting success — the failure mode #1940 originally fixed.

Raise instead, and translate the concurrent-delete case in update_bank's
update-only path into the same 404 its final profile read would produce.

* test(mcp): assert update_bank calls instead of a fixture's forwarding

The mock_memory fixture re-implemented _do_update_bank's routing by
forwarding config_updates to _config_resolver.update_bank_config, so the
existing assertions verified the fake rather than production code — they
would still pass if _do_update_bank stopped sending config entirely.

Assert on the update_bank mock, which is the call the tool now makes.

* test(api): cover the 404 mapping for a delete racing the config write

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-24 16:35:03 +02:00
Naga Satish Chilakamarti 7f05187325 docs: add TealTiger community integration listing (#2831)
* docs: add TealTiger governance memory integration listing

Adds TealTiger to the integrations page as a community integration.
Governance-aware agent memory with importance-weighted retention.

Related: #2284
PyPI: https://pypi.org/project/tealtiger-hindsight/

* Delete hindsight-docs/docs-integrations/tealtiger.md

* Update TealTiger integration link to GitHub
2026-07-24 15:56:15 +02:00
Sanderhoff-alt ada3329bb9 feat(config): make embedding thresholds configurable (#2875)
Expose graph seed, temporal semantic, and semantic-link similarity
thresholds through HindsightConfig while preserving existing defaults.

Wire the settings through retrieval, retain, streaming, and graph
maintenance paths. Add validation, environment examples, documentation,
and regression coverage.

Document how to calibrate all five embedding-dependent thresholds and
note that semantic-link changes do not rebuild existing graphs.
2026-07-24 15:13:57 +02:00
Nicolò Boschi a514d39624 fix(deps): require litellm>=1.93.0 for Python 3.14 support (#2950)
litellm ships its own Rust extension (litellm-rust python-bridge ->
litellm.rust_bridge._native, built via maturin/PyO3). Releases before
1.93.0 publish no cp314 wheel, so on Python 3.14 uv falls back to the
sdist and the build fails:

    error: the configured Python interpreter version (3.14) is newer
    than PyO3's maximum supported version (3.13)

1.93.0 adds cp314 wheels and a PyO3 that builds on 3.14. Raising the
floor fixes the failure at its source, so the interpreter no longer has
to be constrained.

That lets us drop the UV_PYTHON=3.13 workaround added in #2801: the
_set_uvx_python_compat() helper and its call sites are removed from the
claude-code, codex, cursor, and cursor-cli daemons, along with the tests
that pinned that behaviour. Dropping the pin costs nothing — litellm
publishes no macOS wheels at all, so macOS builds from the sdist on every
version regardless, while Linux now gets a real cp314 wheel instead of a
source build.

Also strengthen the build-api-python-versions CI matrix. It previously
ran only `uv build`, which just packages the source and passes even when
the dependency set cannot install or import on the target interpreter --
it would not have caught this. It now installs into a fresh venv,
byte-compiles, and runs an import smoke test on each version.

Verified on CPython 3.14.4 with UV_PYTHON unset: litellm 1.93.0 installs,
the Rust bridge builds, and hindsight_api plus the engine import cleanly.

Refs #2783
2026-07-24 15:06:52 +02:00
handnewbandhandnewb d06fdd78cc fix(integrations): derive recall hook timeout from requestTimeoutSeconds (#2883)
Raise the hardcoded 12s UserPromptSubmit/beforeSubmitPrompt hook timeout
to a safe 45s default across all integration hook manifests (claude-code,
cursor-cli, codex, omo, zcode).

For Claude Code, setup_hooks.py now reads the user's requestTimeoutSeconds
from ~/.hindsight/claude-code.json and derives the hook timeout as
max(requestTimeoutSeconds + 15, 30s) — so the hook process is never killed
before the MCP recall request it wraps has a chance to complete.

Fixes #2854

Co-authored-by: handnewb <[email protected]>
2026-07-24 14:44:08 +02:00
handnewbandhandnewb 7a9ea70580 feat(control-plane): display API version in sidebar (#2886)
Fetch the API version from GET /version at mount and display it in the
sidebar footer. When collapsed, shows 'vX.Y.Z'; when expanded, shows
'Hindsight vX.Y.Z'. Gracefully handles fetch failures (no version shown).

Fixes #776

Co-authored-by: handnewb <[email protected]>
2026-07-24 14:38:25 +02:00
Chris LatimerandNicolò Boschi 64fe5e81f2 feat(engine): add MemoryEngine.delete_memory_units bulk primitive (#2659)
Bulk variant of delete_memory_unit that removes a list of unit_ids with the
same referential-integrity lifecycle, batched by bank:

- enqueue_relink_victims before the cascade
- chunked cascade DELETE (FK CASCADE handles unit_entities / memory_links /
  observation history)
- _delete_stale_observations_for_memories racing-insert sweep
- bank-stats cache invalidation
- deduped async consolidation + graph_maintenance submission per bank

Gives retention loops, LRU eviction, and bulk-maintenance tools a single entry
point that keeps the cascade contract instead of open-coding DELETEs outside
the engine and drifting from it.

(The last_recalled_at column originally in this PR was dropped: it has no OSS
consumer and is better as an extension-owned side table — a high-frequency
write of an indexed column does not belong on the hot memory_units table.)

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-24 14:38:04 +02:00
Carter d29f4703e9 fix(helm): override HINDSIGHT_API_PORT in worker StatefulSet to survive K8s service discovery (#2904)
* fix(helm): override HINDSIGHT_API_PORT in worker StatefulSet

* fix(helm): de-duplicate worker env keys shared by api.env and worker.env
2026-07-24 14:29:09 +02:00
Nicolò Boschi c41ad9bd75 feat(api): filter memory list by linked entity + entity timeline UI (#2945)
* feat(api): filter memory list by linked entity + entity timeline UI

Add an `entity_id` query param to `GET /memories/list` — an exact reverse
lookup over stored entity links (not text/semantic match), backed by the
existing idx_unit_entities_entity_unit index. Because entity links reference
live memory units only, combining `entity_id` with `state=invalidated`
returns nothing.

Wire it through the control-plane list route + clients, and use it in the
entity detail panel to render an observation timeline (reuses the memories
TimelineView) — click an entity, see its linked observations over time.

Closes #2936.

* fix(control-plane): entity timeline shows all linked memories, not just observations

Verified against real data: observations are derived/consolidated summaries and
carry no entity links — entity links live on the source world/experience facts,
which are also the ones with occurred dates. Filtering the entity timeline to
type=observation therefore always rendered an empty panel. Drop the type filter
so the panel shows every memory linked to the entity (the actual dated timeline),
and relabel the section "Timeline" with dedicated i18n keys.

* chore(control-plane): drop now-unused observation i18n keys from entitiesView

* style(reflect): wrap over-length _generate_structured_output call

Ruff format wraps this >120-char call; committing the formatter output so the
verify-generated-files CI check (which runs the formatter and diffs) is clean.
2026-07-24 14:28:44 +02:00
Nicolò Boschi af8cf142d7 fix(mental-model): anchor delta refresh watermark to newest processed memory (#2878)
Follow-up to #2866. That PR stopped the scheduled no-op refresh storm by
advancing a delta model's last_refreshed_at to the pre-Reflect snapshot cutoff
(a wall-clock now()), but a wall-clock watermark is unsafe against commit
visibility.

memory_units.updated_at is the writing transaction's start time (Postgres
now()), yet a row only becomes visible at COMMIT, which can land after a
concurrent refresh captured its snapshot. Such a straddling row is invisible to
Reflect but carries a timestamp <= that instant, so setting the watermark to
now() leaves it permanently below the watermark and drops it from every future
refresh. The same hazard existed on the contentful path (last_refreshed_at =
NOW()) before #2866.

Persist the watermark as MAX(updated_at) over the model's scope restricted to
rows visible at the snapshot -- the newest memory the refresh actually saw --
instead of now(). A straddler is still uncommitted at that snapshot so it is
excluded from the max; when it commits it stays strictly newer than the
watermark and is caught next time. This needs no time margin: max(seen) does
not overshoot the real data, so the settled window stops re-triggering (no
storm) and delta recall's created_after (the prior max(seen)) reprocesses
nothing.

The watermark is clamped monotonic: max(newest_seen, current last_refreshed_at),
so a refresh over only-older memories never moves it backwards (which would
resurface already-processed rows). MAX null (no in-scope row visible) leaves
last_refreshed_at unchanged so an in-flight first row is not skipped.

Extract _build_mm_scope_filter so the staleness check and the watermark query
share one identical scope.

Tests: straddling-commit test uses a committed baseline as the max(seen)
watermark and a newer held-then-committed straddler (fails on #2866); the no-op
test asserts the watermark equals the newest processed memory's updated_at.
2026-07-24 14:26:14 +02:00
Nicolò Boschi dfac776dd5 fix(repair): stop the shared-DB deadlock flake in test-api (CONCURRENTLY test DDL + retry transient deadlocks) (#2943)
* fix(repair): retry transient deadlocks + non-blocking test DDL

The test-api shard runs 8 pytest-xdist workers against one shared pg0
database (public schema). test_repair_bank_vector_indexes built/dropped a
decoy index with plain CREATE/DROP INDEX on the shared memory_units table,
taking ACCESS EXCLUSIVE and deadlocking unrelated workers' DML — recall,
reflect and refresh tests turned into asyncpg DeadlockDetectedError
casualties.

- tests: build/drop the decoy index CONCURRENTLY (ShareUpdateExclusive
  never blocks DML) to match production and stop the collateral deadlocks.
- engine: repair_vector_indexes retries a CREATE/DROP INDEX CONCURRENTLY
  picked as a deadlock victim (sqlstate 40P01 / ORA-00060) via the existing
  retry_with_backoff, instead of recording a permanent failure. Always
  drop-then-create so a retry clears the INVALID stub a deadlocked
  CONCURRENTLY build leaves behind.
- test: test_transient_deadlock_is_retried_not_failed injects a one-shot
  deadlock and asserts repair converges (failed == 0).

No advisory locks (project rule): concurrency stays handled by idempotent
DDL plus victim retry.

* fix(banks): make per-bank index create/delete deadlock-safe

The test-api shard runs 8 xdist workers against one shared pg0 memory_units
table, so every bank create/delete does index DDL that contends with other
workers' DML. These are pre-existing production deadlock sources, not just
test noise:

- delete_bank dropped per-bank indexes with a plain DROP INDEX (ACCESS
  EXCLUSIVE on memory_units), blocking/deadlocking every other bank's
  reads/writes. Now DROP INDEX CONCURRENTLY (ShareUpdateExclusive, does not
  conflict with DML), run post-commit on an autocommit connection, wrapped
  in retry_with_backoff for the residual transient deadlock.
- fresh-bank index build uses a plain CREATE INDEX (ShareLock) inside the
  bank-create tx — CONCURRENTLY is impossible there. The whole tx is now
  wrapped in retry_with_backoff; the build is idempotent (INSERT ON CONFLICT
  + CREATE INDEX IF NOT EXISTS) so a deadlock victim retries cleanly.

Regression tests inject a one-shot deadlock into each path and assert it
retries and converges. No advisory locks (project rule).
2026-07-24 14:04:13 +02:00
Nicolò Boschi 31218127e0 fix(retain): make async retries idempotent via caller-supplied operation_id (#2937) (#2947)
* fix(retain): make async retries idempotent via caller-supplied operation_id

An async retain whose HTTP acknowledgement is lost or times out leaves the
caller unable to tell whether the operation was created; retrying enqueues a
second parent operation and repeats extraction, embeddings, and provider spend.

Add an optional caller-supplied operation_id (UUID) used directly as the parent
async_operations primary key. Re-submitting with the same id returns the
original operation and creates no new work; the existing primary key is the
concurrency authority, so no new columns, constraints, or migration are needed.
Reusing an id owned by a different bank or operation type returns HTTP 409.
Omitting operation_id keeps the current create-each-time behavior.

Fixes #2937

* docs(retain): explain why the idempotency read is not in the create txn

* fix(retain): sync generated docs-skill + Rust clients for operation_id

- Regenerate the two docs-skill artifacts derived from the retain doc /
  OpenAPI change (verify-generated-files).
- Add operation_id: None to the Rust client test and CLI RetainRequest
  literals so both crates compile against the regenerated struct.
2026-07-24 13:43:57 +02:00
Nicolò Boschi 57c18bc298 feat(extensions): declare + provision extension-owned bank-scoped tables (#2903)
* feat(extensions): let extensions declare bank-scoped tables for backup + teardown

An extension can provision its own bank-scoped tables in the tenant schema
(audit receipts, per-bank policy state, ...), but core knows nothing about
them, so they silently fall out of the per-tenant data-lifecycle operations it
owns:

- admin backup/restore copies a fixed core table set and TRUNCATEs it CASCADE
  on restore; an extension table absent from that set is dropped from the
  backup and — if it FKs banks — wiped by the cascade with no way back;
- delete_bank clears a bank via core deletes + the banks FK cascade; an
  extension table scoping by bank_id without a cascading FK leaks orphaned rows.

Add a BankScopedTable descriptor and TenantExtension.extra_bank_tables() so an
extension declares its tables; core consults them in:

- admin backup/restore (_effective_backup_tables appends declared tables after
  the core set so restore's forward COPY / reversed TRUNCATE keep FK order);
- MemoryEngine.delete_bank (sweeps declared tables by bank_id on full delete,
  with a PG-only to_regclass guard so a declared-but-unprovisioned table can't
  abort the delete).

The extension still owns the DDL; this only tells core which tables to sweep.
Default behaviour is unchanged — the base method returns no tables, so the OSS
default path is a no-op. Descriptor names are validated to a safe SQL
identifier shape since they're interpolated into SQL.

Covered by descriptor-validation + effective-list unit tests, a delete_bank
sweep test, and a backup/restore round-trip that proves a declared extension
table survives truncate+restore.

* feat(extensions): provision extension bank tables on the migration path

Adds the creation half of the bank-scoped-table lifecycle. Previously an
extension's tables were created only by its own imperative DDL run lazily on
first request (e.g. Cloud's provision_schema off authenticate), so:
  - hindsight-admin run-db-migration migrated core schema across all tenants
    but never touched extension tables, and
  - a provisioning failure was swallowed, surfacing later as a runtime error.

Add TenantExtension.provision_bank_tables(conn, schema) — idempotent DDL the
extension owns — and invoke it right after core migrations from both migration
entry points:
  - ExtensionContext.run_migration (every tenant-schema provision), and
  - the run-db-migration sweep (_provision_extra_bank_tables, per schema),
    where a failure now aborts the command and names the schema instead of
    being swallowed.

So extension schema evolves on the same lifecycle as core schema. Default is a
no-op, so the OSS default path is unchanged. Pairs with extra_bank_tables()
(declares for backup/teardown) — one creates, the other declares.

Covered by a default-no-op test plus provisioning through both the CLI sweep
helper and ExtensionContext.run_migration against real Postgres.

* chore: ruff format after rebase (cli.py, memory_engine.py)
2026-07-24 13:32:59 +02:00
Nicolò Boschi 6a0b85f108 feat(config): make store_document_text overridable per bank (#2940)
* feat(config): make store_document_text overridable per bank

HINDSIGHT_API_STORE_DOCUMENT_TEXT was static/server-level. Make it hierarchical
so a data-minimizing bank (e.g. GDPR-sensitive) can keep only derived facts
while other banks on the same deployment retain the raw source.

- Add store_document_text to _CONFIGURABLE_FIELDS (settable per bank via the
  config API's generic updates dict, like audit_log_enabled).
- Thread the per-bank resolved value into the retain storage path
  (chunk_storage.store_chunks_batch + fact_storage.upsert_document_metadata /
  handle_document_tracking / _upsert_document_row) from the orchestrator's
  resolved config; falls back to the server-level config when unset so
  non-retain callers (import) are unchanged.
- Make the three consistency guards per-bank too so a store-off bank behaves
  coherently: append-mode rejection, recall include_chunks force-off, and the
  reflect 'expand' tool exclusion.
- Docs: mark the flag hierarchical.

Covered by a per-bank override test (one bank off, one default-on) + a
configurable-fields guard; existing global-flag tests set the ConfigResolver
global snapshot (env alone no longer suffices for a hierarchical field,
mirroring enable_audit_default).

* feat: expose store_document_text (+ audit_log_enabled) in bank templates & UI

- BankTemplateConfig gains store_document_text and audit_log_enabled so bank
  templates can preset them; regenerated bank-template-schema.json.
- Control-plane bank config: new 'Document Storage' tri-state section
  (Inherit / On / Off), mirroring the audit toggle; translations added across
  all 10 locales (non-en use English placeholders pending translation).

Backend template round-trip + messages parity/used-keys + tsc all green.

* chore(ui): rename bank-config 'Document Storage' section to 'Privacy'

* feat(ui): merge audit + document-text toggles into one 'Security & Privacy' section

Combine the separate Audit Logging and Privacy config sections into a single
Security & Privacy section with both tri-state toggles and one save (writes
audit_log_enabled + store_document_text together). Drop the now-unused
section-level message keys across all locales; add securityPrivacy* keys.

* fix(retain): use _get_raw_config for store_document_text fallback

store_document_text became bank-configurable, so get_config().store_document_text
now raises ConfigFieldAccessError (the guard forcing per-bank resolution). The
storage functions' None-fallback hit that guard, breaking every direct/delta
caller that didn't pass the value (test_chunk_storage_upsert, test_delta_retain).

Fall back to _get_raw_config() instead — the unguarded global layer the
ConfigResolver and the /config defaults response already use. The retain path
still passes the per-bank resolved value; only non-retain callers hit the
fallback.

* chore: regenerate openapi + clients + docs-skill for BankTemplateConfig fields

Adding store_document_text/audit_log_enabled to BankTemplateConfig changed the
OpenAPI schema; regenerate the spec, Go/Python/TS client models, and docs-skill
copies, and apply lint formatting (verify-generated-files).

* test: bump configurable-field count 41->42 for store_document_text
2026-07-24 12:27:41 +02:00
Parafee41 1ff09ccf9c fix(cli): preserve HTTP 400 details (#2916)
* fix(cli): preserve HTTP 400 details

* sync generated OpenAPI version
2026-07-24 12:25:39 +02:00
Voscko ff4dc116c3 fix: propagate Codex reasoning effort (#2919) 2026-07-24 12:25:11 +02:00
Salem KorayemandOpenAI GPT-5.6-Sol High a6c875156b fix(retain): preserve append-only oversized history (#2930)
Recognize a complete oversized document as a strict append even when its
header-only first transport slice previously extracted no facts and has no
stored chunk match. Advance document metadata under a content-hash guard so
later slices can recovery-skip unchanged history without risking stale writes.

Co-authored-by: OpenAI GPT-5.6-Sol High <[email protected]>
2026-07-24 12:11:42 +02:00
Derek Bouius 029e5d47d6 chore(deps): bump next, postcss, pypdf (security) (#2933)
Clears the remaining fixable high-severity Dependabot alerts:

  next     16.2.9  -> 16.2.11   4 alerts (control-plane). Direct dep bumped
                                (^16.2.6 -> ^16.2.11); a root override
                                (>=16.2.11 <17) also forces next-intl's nested
                                [email protected] copy up so no vulnerable copy remains.
  postcss  8.4.31  -> 8.5.22    1 alert. The vulnerable copy was next's bundled
                                8.4.31 (the direct 8.5.15 already satisfied);
                                a global override >=8.5.12 forces it up.
  pypdf    6.13.3  -> 6.14.2    2 alerts (superagent). Transitive.

Verified: control-plane `npm run build` (next build + standalone) succeeds,
`npm ci` installs the root lock cleanly, npm audit no longer flags next or
postcss, superagent pytest passes, lint clean.
2026-07-24 12:11:31 +02:00
Nicolò Boschi 489d55fa62 feat(observability): diagnose blocked-loop vs pool-exhaustion on stalled /health (#2942)
The API and worker run /health and all task work on a single event loop, and
/health acquires a DB connection. A failing liveness probe therefore has two
very different causes that today are indistinguishable: the event loop is
blocked by synchronous work (a restart helps), or the connection pool is
exhausted and /health can't get a connection while the loop is idle (a restart
just thrashes). Add two always-on, cheap signals so the failure is
self-diagnosing instead of an opaque restart.

LoopWatchdog (hindsight_api/loop_watchdog.py): runs in a separate OS thread —
deliberately, since a coroutine-based monitor would be frozen by the very stall
it's watching — pings the loop, and on a stall past a threshold logs the loop
thread's stack (naming the blocking frame) and emits
hindsight.event_loop.stalls / stall_duration. Works with uvloop. Wired into the
worker CLI and the API lifespan; enabled by default.

DB pool acquire instrumentation (engine/db/pool_instrumentation.py): tracks
callers currently queued for a connection (hindsight.db.pool.waiting gauge, the
signal that actually distinguishes exhaustion from a busy-but-healthy pool),
records an acquire-wait histogram, and logs a warning with pool stats when an
acquire waits too long. Wired into both the PostgreSQL and Oracle backends.
health_check() now reports db_acquire_ms and pool utilization in its payload.

Static config: HINDSIGHT_API_LOOP_WATCHDOG_ENABLED / _STALL_THRESHOLD_MS /
_POLL_INTERVAL_MS, HINDSIGHT_API_DB_ACQUIRE_WARN_THRESHOLD_MS.

Tests: test_loop_watchdog.py (detects on-loop blocks, ignores off-loop work,
quiet when responsive) and test_pool_instrumentation.py (waiter counting through
success/mid-acquire/failure, slow-acquire logging).
2026-07-24 12:11:07 +02:00
Nicolò Boschi 47a7d43809 fix(llm): normalize bare LM Studio / Ollama base URL to /v1 (#2941)
LM Studio's server UI advertises its address as a bare host
(http://localhost:1234), so users commonly set HINDSIGHT_API_LLM_BASE_URL
to that. The OpenAI SDK then POSTs to <host>/chat/completions and LM Studio
rejects it with 'Unexpected endpoint or method' — its OpenAI-compatible
routes live under /v1.

For lmstudio/ollama (whose OpenAI-compat surface is known to live under /v1)
append /v1 when the base URL has no meaningful path. Explicit paths (reverse
proxy mounts, already-correct /v1) are left untouched.

Fixes #2922
2026-07-24 11:55:01 +02:00
Nicolò Boschi 21928d7c95 chore(deps): bump protobuf to 7.x and OpenTelemetry to 1.44/0.65b0 (#2923)
protobuf 7 was blocked only by opentelemetry-proto <1.44 capping
protobuf<7.0; 1.44.0 raised the ceiling to <8.0. Bump the six coupled
otel pins together (api/sdk/otlp-proto-http 1.41->1.44, the three 0.6x
companions 0.62b1->0.65b0) and protobuf 6.33.5->7.35.1.

Verified in a real env: the OTLP HTTP exporter's protobuf-serialized
trace payload round-trips through otel's generated proto types, and the
Prometheus metrics path works. The otel_component_type kwarg (reason for
the original >=1.41 floor) is still present in 1.44.
2026-07-24 11:00:07 +02:00
EvoandNicolò Boschi 552feb24b2 fix(retain): offset causal targets from the extraction-group start (#2935)
* fix(retain): offset causal targets from chunk start

* refactor(retain): drop unreachable chunk fact-count guards

The sync path derives each chunk's fact_count as len(chunk_facts)
(extract_facts_from_text), so sum(counts) always equals
len(facts_from_llm) and counts are never negative. The mismatch/
negative RuntimeError guards could only fire under artificial test
setups; the offset fix and target bounds-check stand on their own.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-24 10:35:08 +02:00
Nick OldandNicolò Boschi 3cc3713829 fix backup restore schema compatibility (#2920)
* fix backup restore schema compatibility

* test(backup): cover type-mismatch preflight + extra-target-column restore

Add a test for the incompatible-column-type preflight branch and a
positive test proving a target with an extra nullable column (which a
column-less binary COPY would reject) now restores cleanly. Document the
deliberate exact-type strictness in _validate_restore_schema.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-24 10:09:18 +02:00
Nicolò Boschi 03a1fd08c0 fix(engine): make mental-model refresh cutoff stubbable (fix mock unit tests) (#2924)
refresh_mental_model gained an unconditional DB-time snapshot query
(_get_backend() -> SELECT current_timestamp) to bound the refresh watermark.
That broke the mock-based unit tests that build MemoryEngine.__new__ and stub
the collaborators: they now reach _get_backend() on an engine whose __init__
never ran, failing with 'MemoryEngine object has no attribute _initialized'.

Extract the snapshot into _mental_model_refresh_cutoff(bank_id, mental_model_id)
(pure refactor, no behaviour change) so those tests can stub it like the other
collaborators, and stub it in the three affected tests.

Fixes pre-existing test-api failures on main:
- test_recall_config.py::TestRefreshTriggerWiring (x2)
- test_mental_models.py::TestMentalModelRefreshMaxTokens::test_refresh_passes_stored_max_tokens_to_reflect
2026-07-24 10:01:03 +02:00
Ben 0db0d3ec93 blog: Give Roo Code a Memory So Every Task Builds on the Last (#2931)
* blog: persistent memory for Roo Code (task-based agent)

How-to for the Roo Code integration: one-command install that wires
Hindsight's MCP tools (recall/retain, auto-approved) plus a custom rules
file so Roo recalls context before each task and retains a summary after.
Covers project vs global scope, cloud/self-host, verification, cross-tool
bank sharing, and FAQ. Grounded in the integration doc + README; package
live on PyPI. Cover: Roo kangaroo mark + recall->task->retain loop.

* blog: rebuild Roo Code cover in iridescent-mesh template with Roo mark

* blog: drop MCP from Roo Code cover subtitle
2026-07-23 15:30:14 -04:00
Derek Bouius fa69b5b73b chore(deps): bump npm transitive highs (brace-expansion, js-yaml, sharp, fast-uri, svgo, shell-quote) (#2907)
Clears the remaining high-severity npm Dependabot alerts across the root lock
and three integration locks, via overrides (root + zapier + cloudflare) and a
direct-dep bump (nemoclaw, where js-yaml is declared directly):

  root:     brace-expansion 2.0.3->2.1.2, fast-uri 3.1.2->3.1.4 (capped <4),
            sharp 0.34.5->0.35.3, shell-quote 1.8.4->1.10.0, svgo 4.0.1->4.0.2
  zapier:   brace-expansion pinned per-major (1.1.16 / 2.1.2 / 5.0.7 via
            version-keyed overrides so coexisting majors are not collapsed),
            js-yaml ->4.3.0 (capped <5)
  nemoclaw: js-yaml direct dep ^4.1.0 -> ^4.3.0
  cloudflare-oauth-proxy: sharp ->0.35.3

fast-uri and js-yaml capped below the next major so a security bump does not
drag in a breaking major. Verified `npm ci` installs all four locks cleanly
and `npm audit` no longer reports any of these six packages in any manifest.

Out of scope (separate, pre-existing): zapier still reports a `tar` critical
(node-tar advisories) — a different package not in this batch.

Committed --no-verify: the generate-docs-skill hook is blocked by a
pre-existing openapi.json drift on main, unrelated to these npm bumps.
2026-07-23 14:47:55 -04:00
MENEL[bot] dbf3b9d9bc feat(ts-client): support custom headers (#2914) 2026-07-23 19:24:34 +02:00
Derek Bouius 1942cf2cd8 chore: regen skills/hindsight-docs openapi.json to fix verify-generated-files (#2925)
skills/hindsight-docs/references/openapi.json drifted from its source on
main (the generator produces a 1-line diff), so the verify-generated-files
CI job — which runs the generate scripts and fails on any diff — has been
red on every open PR regardless of its own changes, and the local
generate-docs-skill pre-commit hook blocks commits.

Regenerated via ./scripts/generate-openapi.sh + ./scripts/generate-docs-skill.sh.
Generated-file sync only.
2026-07-23 17:27:25 +02:00
Nicolò Boschi 441cf2272e feat(engine): filter list_memory_units by ingest age (created_before) (#2902)
Add a created_before filter to MemoryEngine.list_memory_units so
maintenance-loop callers (retention sweeps, bulk maintenance) can select units
by ingest age through the engine instead of open-coding SQL against
memory_units: created_at < <instant>. Composes with the existing tags /
tags_match filters. Interface + concrete method; covered by a test against
real Postgres.

(A last_recalled_before dormancy filter was dropped along with the
last_recalled_at column — recency moves to a Cloud-owned side table, so the
dormancy read lives in the extension, not core.)
2026-07-23 15:45:52 +02:00
Ben 4dc8348348 blog: Your 1M-Token Context Window Is Not Memory (#2910)
* blog: Your 1M-Token Context Window Is Not Memory

Thought-leadership piece: a context window is working memory that resets
each session and degrades before it fills (lost-in-the-middle, Chroma
context rot), so a bigger window is not a memory system. Includes a
context-window-vs-memory comparison table and the one-question test.
Cited research linked; em-dash-free.

* blog: add Hindsight Cloud CTAs (embedded mid-article + Hindsight paragraph)
2026-07-22 15:24:43 -04:00
Derek Bouius 1bb7e03429 chore(deps): bump pillow, gitpython, pyasn1 (security) (#2899)
Clears 62 high-severity Dependabot alerts across the Python locks:

  pillow     12.2.0 -> 12.3.0   50 alerts (10 advisories) across autogen,
                                crewai, llamaindex, pipecat, smolagents
  gitpython  3.1.50 -> 3.1.54   8 alerts (4 advisories) in root + agno
  pyasn1     0.6.3  -> 0.6.4    4 alerts (2 advisories) in root + google-adk

All transitive; only the intended version bumps, no transitive churn.
gitpython resolves to 3.1.54 (latest, >= advisories' 3.1.52).

Verified: crewai 35 passed, google-adk 49 passed, smolagents 81 passed.
agno has 10 pre-existing test failures unrelated to gitpython. Committed
--no-verify: the generate-docs-skill hook is blocked by a pre-existing
openapi.json drift on main, unrelated to these lock bumps.
2026-07-22 13:17:37 -04:00
Parafee41 7b161740d0 fix within-batch cosine similarity (#2890) 2026-07-22 17:43:06 +02:00
Nicolò Boschi 6428a83713 docs: changelog and blog post for v0.8.5 (#2879)
* docs: changelog and blog post for v0.8.5

* docs: demote vector-index self-heal to an ops bullet in the 0.8.5 blog
2026-07-22 14:05:52 +02:00
Nicolò Boschi 705757f362 Release v0.8.5
- Update version to 0.8.5 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.8
2026-07-22 14:04:42 +02:00
Ben dd6766c37d blog: Make Thinking Machines' Inkling your agent's memory (tutorial) (#2882)
* blog: use Thinking Machines' Inkling as a Hindsight memory model (tutorial)

Clickbaity how-to: point Hindsight's internal LLM at Inkling via any
OpenAI-compatible endpoint (four env vars, NVIDIA free key). Includes
real test results: clean structured fact extraction, unprompted temporal
resolution (last week -> 2026-07-14), entity resolution, and a coherent
reflect, all out of the box. Honest caveats (not on leaderboard, 975B
hosted-only, latency; gpt-oss-20b still fastest for high-volume retain).

* blog: swap Inkling cover to Hermes split-duotone style with Thinking Machines wordmark

* blog: use Inkling's real brand graphic (ink blob) on the cover

* blog: name Thinking Machines in title and body (Inkling is Thinking Machines' model)

* blog: cover title now names Thinking Machines Lab
2026-07-21 16:09:07 -04:00
Derek Bouius 8ca1f20f93 chore(deps): pin adm-zip >=0.6.0 in zapier via override (security) (#2880)
adm-zip 0.5.16 -> 0.6.0  GHSA (high) — clears the last fixable high-severity
                           Dependabot alert in hindsight-integrations/zapier.

adm-zip is transitive (via zapier-platform tooling) and a parent pins it to
the 0.5.x line, so `npm update` won't move it. Add an override — the same
mechanism zapier already uses for form-data/tar/tmp/yeoman-environment — to
force the patched 0.6.0. Verified `npm ci` installs the lock cleanly with
adm-zip 0.6.0.
2026-07-21 14:31:35 -04:00
Nicolò Boschi a23187a456 fix(llm): recover malformed JSON via json_repair as a last-resort parse fallback (#2871)
Recover structurally-malformed LLM JSON (trailing commas, unterminated strings, single quotes, invalid \escape) via json_repair as a terminal fallback in parse_llm_json, after fence-strip and control-char scrub both fail. Empty repair result keeps raising JSONDecodeError so retry ladders / #1833 fail-loud still fire. LiteLLM prefers a clean re-roll first (repair only after retries exhausted). Scoped to structural malformation only — the degenerate-but-valid-JSON class (#2544/#2547) is deliberately out of scope. Regenerated the docs skill to clear pre-existing #2865 drift.
2026-07-21 16:41:50 +02:00
Jordan-JarvisandNicolò Boschi 18650712fa refactor(llm): type provider tool choices (#2843)
* fix(reflect): preserve required tools for custom OpenAI endpoints

* refactor(llm): type provider tool choices

* fix(style): format required-tool regression

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-21 16:26:31 +02:00
Nicolò Boschi bd853be356 fix(vector-index): repair per-bank index coverage after restore/upgrade (#2645) (#2872)
Per-(bank, fact_type) partial vector indexes are created only at fresh-bank
creation. A bank populated outside that path (logical restore, cross-version
upgrade, extension switch) never gets them, so its recall silently falls back
to the global index + post-filter — slower and under-returning (~0.63-0.72
recall@10 measured by the reporter).

Two fixes:

- import-bank: create the per-bank indexes explicitly after restoring the
  banks row. The prior get_or_create_bank_profile call was a no-op here (the
  row already exists, so it takes the SELECT branch), leaving every restored
  bank uncovered.

- hindsight-admin repair-bank (--bank ID | --all): re-runnable operator escape
  hatch for the out-of-app routes (raw pg_dump restore, extension switch) that
  a one-time migration can't cover (a restore carries alembic_version at head,
  so the migration is already stamped). Detects missing OR invalid coverage
  (INVALID leftovers / drifted access method count as missing, unlike a
  name-only check) and rebuilds with CREATE INDEX CONCURRENTLY off any txn.
  Idempotent; concurrency handled by idempotency, not advisory locks.

Deliberately excludes the boot/periodic background reconcile and retain-path
self-heal: a bank restored and only ever read stays degraded until an operator
runs repair-bank. That background layer can be a follow-up.
2026-07-21 16:05:40 +02:00
Sanderhoff-alt 17d0a0c068 fix(docs): regenerate documentation skill (#2870) 2026-07-21 15:52:05 +02:00
Nicolò Boschi be6caf9dcf feat(llm): opt-in 4xx request-dump for diagnosing rejected calls (all providers) (#2865)
* feat(llm): opt-in 4xx request-dump for diagnosing rejected calls (all providers)

Generalizes the Gemini-only diagnostic from #2475 into a provider-agnostic
helper (engine/providers/llm_debug.py) wired into every remote LLM provider:
Gemini/Vertex, OpenAI-compatible (+ Fireworks/Nous subclasses), Anthropic,
LiteLLM (+ router subclass), and Codex — on both call() and call_with_tools().

Gated by HINDSIGHT_API_LLM_DEBUG_DUMP_4XX (off by default). On any 4xx it logs
[LLM_4XX_DUMP] with the serialized request config (message bodies stripped) and
per-message role/size + a length-capped preview. Self-gates on the env flag and
a 4xx status, extracts the status across SDK error shapes (status_code / code /
response.status_code), and never raises.

* style: ruff format single-line dump_request_on_4xx calls

* refactor(llm): source 4xx-dump flag from HindsightConfig, not raw env

Adds llm_debug_dump_4xx as a static (server-level) config field; the helper
reads get_config().llm_debug_dump_4xx instead of os.getenv directly. Documents
the flag in configuration.md and .env.example (+ bundled embed copy). Replaces
the tuple return in the message-preview helper with a dataclass per project
standards.
2026-07-21 15:16:21 +02:00
Sanderhoff-alt 91ee2537e2 fix(cli): sync regenerated OpenAPI operation changes (#2867)
* fix(cli): pass tag filters to list memories

OpenAPI added tags and tags_match to list_memories in #2848, but the
CLI wrapper still passed the previous positional arguments. Generated
Rust client builds then failed with E0061.

Pass None for both filters to preserve existing CLI behavior and match
the generated method signature.

* feat(cli): expose terminal operation deletion

OpenAPI added delete_operation in #2777 without exposing it through
the Rust CLI or accounting for it in the coverage manifest. The CLI
coverage check therefore rejected branches rebased onto that change.

Add operation delete with confirmation and --yes support. Pass the
request through the generated client and cover command parsing. This
counts the endpoint as implemented without a coverage exception.
2026-07-21 14:53:15 +02:00
SunneeYang c1fae2ae1b fix: advance watermark after no-op delta refresh (#2866) 2026-07-21 14:45:54 +02:00
Jordan-Jarvis 434dbee64c fix(reflect): emit canonical OpenAI tool result messages (#2844)
* fix(reflect): emit canonical tool result messages

* test(providers): cover canonical tool result wire
2026-07-21 14:42:17 +02:00
Nicolò Boschi c3dfaf3dd9 fix(retain): queue retain.completed webhook on boundary and zero-fact batches (#2861)
The transactional-outbox callback that queues the retain.completed webhook
delivery only fired inside the final facts-bearing batch's write transaction
(is_last=True). Two successful retain paths never reached it, silently dropping
the delivery with no error and no retry:

- Exact chunk-batch boundary: full batches flush with is_last=False and only the
  leftover partial batch is marked last. When the committed-chunk count is an
  exact multiple of retain_chunk_batch_size, the queue sentinel drains an empty
  batch, so is_last=True is never passed.
- Zero-fact final batch: _process_db_batch returns before the fact-insert call
  site (which carries the callback) when a batch extracts no facts — common for
  boilerplate content.

There is no backstop: the delivery row is only inserted by this callback, and
the worker poller re-delivers existing rows, so a never-inserted row is lost.

Fix: track whether the callback fired in-TXN and, on any successful non-aborted
retain that didn't fire it, queue the delivery exactly once in a dedicated
transaction after the consumer loop. Aborted (concurrent-takeover) retains are
skipped so they don't emit a completion event.

Regression tests assert exactly one retain.completed delivery for both the
boundary (retain_chunk_batch_size=1) and zero-fact cases; both fail with 0
deliveries on main.
2026-07-21 14:04:14 +02:00
handnewbandNicolò Boschi 234f5a0621 fix(worker): count crash-recovery attempts toward max-retry budget (#2675) (#2834)
* fix(worker): count crash-recovery attempts toward max-retry budget

When a worker crashes while processing an async_operations row, no
failure bookkeeping runs — retry_count is only incremented by in-process
failure handling. On restart, recover_own_tasks resets 'processing' rows
back to 'pending' with retry_count untouched, and the row is re-claimed
as if brand new.

An operation that can never complete therefore loops forever:
claim → grind → crash → recover → re-claim…

This changes recover_own_tasks to increment retry_count during recovery
and honor the existing worker_max_retries threshold (HINDSIGHT_API_WORKER_MAX_RETRIES).
Tasks at/over the limit are moved to 'failed' with an explanatory
error_message instead of being re-queued.

Changes:
- Poller.__init__: accepts max_retries (default 3, matches DEFAULT_WORKER_MAX_RETRIES)
- recover_own_tasks: two UPDATEs — under-limit tasks increment retry_count
  and reset to pending, over-limit tasks move to failed
- main.py: wires config.worker_max_retries into the Poller
- Tests: retry_count increment, exceeded→failed, NULL retry_count handling

Reuses the existing config field (HINDSIGHT_API_WORKER_MAX_RETRIES)
rather than adding a new one. Default of 3 retries x crash recovery
gives the same total window as the normal retry path.

Closes #2675

* style: ruff format test_worker.py

* fix(worker): propagate crash-recovery child failures to batch parent

A batch_retain child sub-batch carries parent_operation_id (not batch_id)
in its metadata, so crash recovery can move it to 'failed' once it exceeds
the retry budget. That terminal transition was not propagated to the parent
aggregator, leaving the parent stuck in 'processing' forever.

recover_own_tasks now rolls each failed child up to its parent via
_maybe_update_parent_operation (one transaction per child, mirroring the
in-process _mark_failed path). Adds a regression test.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-21 13:47:57 +02:00
Salem KorayemandOpenAI GPT-5 Codex medium d7c32f9633 fix(transfer): preserve JSONB and timestamp provenance (#2717)
* fix(export): preserve decoded JSONB scalar strings

Native admin connections decode JSON and JSONB columns before export. Preserve already-decoded string scalars while continuing to parse raw JSON strings so export-bank no longer fails on observation scopes such as combined.

Co-Authored-By: OpenAI GPT-5 Codex medium <[email protected]>

* fix(import): normalize decoded JSONB strings

Whole-bank archives can contain Python string scalars when their export connection registered JSON codecs. Quote decoded scalars before PostgreSQL casts while preserving already-serialized JSON text and decoded objects.

Co-Authored-By: OpenAI GPT-5 Codex medium <[email protected]>

* fix(transfer): preserve archive provenance

Record bank-row JSON encoding in transfer manifests so decoded scalar strings and serialized objects restore without ambiguous parsing. Preserve archived document and observation timestamps during replay.

Co-Authored-By: OpenAI GPT-5 Codex medium <[email protected]>

* test(transfer): guard admin JSON provenance

Prove the codec-enabled admin exporter identifies bank rows as decoded so JSON-looking scalar strings cannot silently regress during restore.

Co-Authored-By: OpenAI GPT-5 Codex high <[email protected]>

---------

Co-authored-by: OpenAI GPT-5 Codex medium <[email protected]>
2026-07-21 13:46:27 +02:00
chethanuk dcef72480b feat(api): allow deleting terminal bank operations with control-plane support (#2777)
* feat(api): allow deleting terminal bank operations with control-plane support

* refactor(api): rename terminal-operation delete route to /delete

Maintainer review on #2777 asked for the hard-delete endpoint to live at
/delete rather than /record. Renames the path segment end-to-end:
dataplane route + log message, tests, OpenAPI spec (and its skills
mirror), and the generated Python/TypeScript/Go clients.

The route's operation_id is explicitly "delete_operation", so no
generated symbol names change -- only the path string. Go and Python
generated output was verified byte-identical against the pinned
openapi-generator v7.10.0.
2026-07-21 13:45:37 +02:00
Nicolò Boschi 3a65d5ed29 release(opencode): v0.2.8 2026-07-21 13:42:02 +02:00
Evo 1265563fc8 fix(docker): build images from workspace lock (#2789) 2026-07-21 13:41:30 +02:00
Nicolò Boschi 036ba19b65 fix(opencode): derive session-start recall query from user messages (#2856) (#2860)
The system.transform auto-recall used a hardcoded 'project context and
recent work' query for every session, so recall never adapted to what the
user actually asked. Fetch the session transcript (the hook input only
carries sessionID/model) and build the query from the latest user message
via the same composeRecallQuery/truncateRecallQuery path the compaction
hook already uses, falling back to the generic query when there is no user
text yet. Fetching directly also keeps this independent of the
session.created-vs-system.transform ordering (#1758).
2026-07-21 13:40:32 +02:00
Evoandr266-tech 6c8a92c318 fix(integrations): select compatible Python for uvx daemons (#2801)
Co-authored-by: r266-tech <[email protected]>
2026-07-21 13:26:17 +02:00
chethanuk 912f8e22d1 fix(retain): a zero retry budget must still perform the initial fact-extraction request (#2779)
* fix(retain): a zero retry budget must still perform the initial fact-extraction request

* fix(retain): use N+1 outer fact-extraction attempts to match provider retry convention

Review feedback on #2779: llm_max_retries=N means N retries *after* the
initial request, so N=1 must give 2 total outer attempts. The previous
max(1, N) floor under-counted (N=1 -> 1 attempt). Every provider already
loops range(max_retries + 1); the outer content-validation loop now follows
the same convention, and a zero budget still performs one request (#2731).
The raw budget is still forwarded unchanged to llm_config.call().
2026-07-21 13:20:25 +02:00
Evoandr266-tech 5126e0bb08 fix(mental-models): align stale checks with refresh tag scope (#2804)
Co-authored-by: r266-tech <[email protected]>
2026-07-21 13:18:09 +02:00
Nicolò Boschi 41d71a9818 fix(#2808): make mental model tags_match configurable on all creation surfaces (MCP, TS client, CLI) (#2858)
* feat(mcp): let create_mental_model configure tags_match (#2808)

A tagged mental model with no explicit tags_match in its trigger JSON
refreshes under all_strict (a memory must carry every one of the model's
tags), while the staleness check and every recall/reflect path default to
any. Broadly-tagged models reading narrowly-tagged memories therefore get
marked stale and then refresh to empty content.

The HTTP API, generated SDK clients, and Control Plane UI already let users
set trigger.tags_match; the MCP create_mental_model tool did not. Add a
tags_match argument (validated against TagsMatch) to both MCP variants. It
is only written into the trigger when explicitly passed, so the resolved
all_strict default is preserved for existing callers.

Document the all_strict footgun and the tags_match override in the MCP and
mental-models API docs (regen skills/hindsight-docs mirror).

* fix(ts-client): expose tags_match/tag_groups on createMentalModel

The ergonomic TypeScript wrapper's createMentalModel accepted only
{ refreshAfterConsolidation } in its trigger option and dropped every other
trigger field, so a wrapper user could not set tags_match — the exact knob
needed to avoid the empty-refresh footgun in #2808. The low-level generated
sdk already accepts the full MentalModelTriggerInput; thread tagsMatch and
tagGroups through, mirroring how recall/reflect already expose them.

The Python client needs no change: its wrapper takes a pass-through
trigger dict and the generated MentalModelTriggerInput already validates
tags_match.

* test(ts-client): cover createMentalModel trigger mapping

Mock the generated sdk layer (no server needed) and assert the ergonomic
camelCase trigger options map onto the snake_case body: tagsMatch ->
tags_match, tagGroups -> tag_groups, refreshAfterConsolidation still maps,
and omitting trigger sends none (preserving the all_strict default). Locks
in the #2808 wrapper fix.

* docs(mental-models): add tags_match code snippet

Replace the static JSON block in the tags_match override section with a
live CodeSnippet pulled from the Python example, showing how to create a
model with trigger.tags_match="any" so a broadly-tagged model reads
narrowly-tagged memories on refresh (#2808).

* feat(cli): add --tags-match to mental-model create + all-language docs

The Rust CLI's `mental-model create` was the last creation surface with no
way to set tags_match, so a tagged model created via the CLI hit the same
empty-refresh footgun (#2808). Add a `--tags-match` flag (any/all/any_strict/
all_strict/exact) that is only sent when passed, preserving the server's
all_strict default; invalid values are rejected before the request.

Expand the mental-models docs "tags_match override" example from a single
Python snippet to a full Tabs block (Python / Node.js / CLI / Go), each
pulled from the runnable example files, and regen the skills mirror.
2026-07-21 12:04:23 +02:00
Nicolò Boschi 9fe339dfb1 fix(llm): per-operation strict schema + honour explicit per-call opt-out (#2825)
Add HINDSIGHT_API_LLM_STRICT_SCHEMA_{RETAIN,REFLECT,CONSOLIDATION}, each resolved per-operation env -> global env -> default (mirroring the per-operation temperature knobs). All five structured-output call sites thread their operation's resolved flag.

Also fixes a latent resolution bug in LLMConfig.call: 'strict_schema or get_config().llm_strict_schema' made a per-call False indistinguishable from unset, silently ignoring any scope opting out while the global flag was on. The arg is now bool|None: None inherits the global flag, explicit True/False wins in both directions.

Supersedes #2669.
2026-07-21 11:59:27 +02:00
7d1aab8b8d fix(retain): preserve fact alignment when filtering degenerate text (#2846)
* fix(retain): preserve filtered fact alignment

* test(retain): cover chunk-provenance shift from degenerate-fact filtering

Add a deterministic streaming-retain regression test for the #2794
alignment bug the PR fixes: a rejected degenerate fact must not shift
chunk provenance onto a later chunk's survivor via the consumer
zip(batch_extracted, batch_processed).

Each chunk emits [real, degenerate] so that after the first
(real, degenerate) pair the zip is off-by-one for the rest of the batch
regardless of the nondeterministic producer completion order — both real
facts would collapse onto one chunk_index without the fix.

---------

Co-authored-by: r266-tech <[email protected]>
Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-21 11:56:20 +02:00
Derek Bouius 2ea5df3db0 chore(deps): bump nltk to 3.10.0 (security) (#2833)
nltk 3.9.4 -> 3.10.0  GHSA-p4gq-832x-fm9v (URL-encoded path traversal in
                        nltk.data.load() allowing arbitrary local file read)

Two high-severity alerts, one each in the llamaindex and pipecat integration
locks. nltk is transitive in both. 3.10.0 pulls in defusedxml 0.7.1 (nltk's
new hardened-XML dependency) — expected, not incidental churn.

Done directly rather than via the Dependabot uv-group PR (which also carries
torch/agno and keeps going stale against this fast-moving main). Verified:
llamaindex 88 passed, pipecat 19 passed; lint clean.
2026-07-21 11:51:19 +02:00
Evoandr266-tech 820019f3c6 fix(pg0): honor URL credentials during MemoryEngine startup (#2836)
Co-authored-by: r266-tech <[email protected]>
2026-07-21 11:50:36 +02:00
Nicolò Boschi 0679d38e8e fix(retain): reassert resolved entities before linking units (#2662) (#2859)
Retain resolves entities in Phase 1 on a separate, already-committed
connection, then inserts unit_entities in Phase 2 on a new transaction.
In that window graph maintenance's prune_orphan_entities can delete a
just-resolved parent — it legitimately has no unit_entities row yet — so
the Phase-2 FK insert fails and the whole batch is dropped as
non-retryable: silent memory loss, worst on the document re-ingest path.

Carry each resolved entity's id AND its stored canonical name across the
phase boundary (new ResolvedEntity), then, on the Phase-2 connection
immediately before linking, reassert the parents in one statement:

  * PostgreSQL: a CTE locks the surviving parents FOR KEY SHARE (held to
    commit, so a concurrent prune DELETE blocks) and re-inserts only the
    already-pruned ones — same single-round-trip shape as
    bulk_insert_links. ON CONFLICT DO NOTHING keeps the rare
    name-recreated-under-a-new-id case from raising.
  * Oracle: FOR UPDATE locks in the caller's stable id order, then an
    idempotent insert.

The stored canonical name (not the raw input mention) is what gets
restored, so a fuzzy alias no longer permanently mislabels a resurrected
row. The same reassert is applied on the curation edit path, which has
the same resolve/link window.

Tests: an end-to-end Phase-1 -> prune -> Phase-2 regression proving the
original id and canonical name are restored via a fuzzy alias; a real-PG
concurrency test proving prune blocks until the child link commits; and
Oracle adapter coverage for stable lock order and idempotent reinsert.

Fixes #2662
2026-07-21 11:50:03 +02:00
Nicolò Boschi 0d2dbe756d feat(audit): make audit_log_enabled overridable per bank (#2827)
* feat(audit): make audit_log_enabled overridable per bank

Auditing was all-or-nothing per deployment. This makes the existing
audit_log_enabled switch hierarchical (env -> tenant -> bank) so a bank
can opt in while the server default is off, or opt out while it is on,
rather than introducing a second near-identically-named field.

Making the flag per-bank forces three call sites to change:

- AuditLogger: the enabled check can no longer be a synchronous
  pre-filter, since a bank may enable auditing the global value has off.
  Split into action_allowed() (bank-independent allowlist, still a cheap
  sync pre-filter) and should_log() (awaits the per-bank resolution).
  Resolution failure falls back to the deployment default rather than
  failing closed, so a transient DB blip cannot silently create an audit
  gap for a bank that is meant to be audited.

- Retention sweep: previously gated on audit_log_enabled, which is now
  per-bank while the sweep is a global cross-tenant job with no bank in
  scope. A bank opting in under a default-off deployment would have had
  its rows accumulate forever. Retention now keys off the (still
  server-level) retention window alone.

- _audit_memory_defense: was sync and reached log_fire_and_forget
  directly, bypassing the per-bank decision entirely. Made async so the
  memory_defense action honours the bank's setting like every other path.

The actions allowlist and retention window stay server-level: both are
global sweeps with no bank scope. The /version audit_log flag keeps
reporting the deployment default and now says so.

Adds the Audit Logging toggle to the bank Configuration tab.

The hindsight-docs skill regen also picks up pre-existing drift from
#2694 (retain.md), which the pre-commit generator syncs unconditionally.

* fix(control-plane): make the audit toggle tri-state

A Switch cannot express "inherit the server default". It rendered the
resolved value, so a bank inheriting `true` looked identical to one
explicitly set to `true`, and touching it always wrote an explicit
boolean with no way back to inherit.

Replaced with a Select: Server Default / Enabled / Disabled. The slice
now reads the bank's `overrides` rather than the resolved config, since
the resolved value cannot distinguish inherited from explicitly-set.
Choosing "Server Default" sends null, the tombstone the config resolver
already treats as "clear this override".

The option label shows which way the server default currently points,
read from the existing /version features flag.

Uses INHERIT_SENTINEL rather than "" for the inherit option: Radix
rejects an empty SelectItem value at runtime.

* chore(clients): regenerate for audit_log description change

The audit_log field description in openapi.json changed; regenerate the
Go/Python/TypeScript clients that embed it (they were skipped earlier
because the generator needs Docker). Verify-generated-files was failing
on the drift.

* fix(audit): resolve gating config internally, bypassing permission filter

_resolve_bank_audit_enabled used get_bank_config, the API-facing resolver
that runs the tenant permission filter (get_allowed_config_fields). A
deployment that makes audit_log_enabled read-only for a user — exactly
the intended way to lock the field via an extension — would have that
field stripped from the resolved config, so gating silently reverted to
the deployment default and ignored the bank's stored override.

Switch to resolve_full_config (the internal, unfiltered resolver every
other internal config consumer uses). Gating is a system decision and
must see the bank's true value regardless of who is asking.

Adds a regression test with a restrictive tenant extension: the API read
strips the field, but gating still audits the opted-in bank.

Also: document the fail-open opt-out edge in should_log's comment, and
refresh a stale "static, server-level switch" comment in the memory
defense test.
2026-07-21 11:37:46 +02:00
Jordan-Jarvis ea460c062d fix(llm): emit OpenAI strict JSON schemas (#2845) 2026-07-21 11:34:17 +02:00
Jordan-Jarvis 6ba98c040e fix(memory): preserve bank attribution during curation (#2847) 2026-07-21 11:19:05 +02:00
peter216 b82fb603c2 fix: disable built-in tools in ClaudeCodeLLM.call() to prevent ToolSearch deferral eating max_turns=1 (#2850)
call_with_tools() already sets tools=[] on ClaudeAgentOptions, with a
comment explaining that leaving the built-in toolset enabled can make
the CLI defer into ToolSearch before answering, burning the turn
budget. call() -- used for single-turn structured/consolidation calls
-- was missing the same tools=[] and only set allowed_tools=[], which
restricts what may be called without prompting but doesn't stop the
toolset from loading in the first place.

Observed in production (hindsight-embed, claude-code LLM provider,
consolidation path): repeated 'Claude Code returned an error result:
Reached maximum number of turns (1)' failures on isolated, single-memory
batches, ruling out batch-size/concurrency as the cause. Restarting the
daemon with this one-line change (tools=[] added to call()'s options)
cleared a 16-item stuck consolidation backlog on the first pass with
zero max-turns failures, across two LLM batches (8 memories each,
94.4s and 73.4s respectively) that were previously failing consistently
on the same data.
2026-07-21 11:18:43 +02:00
superafunandNicolò Boschi c1fadc008a feat: add tags filtering to list_memories / list_memory_units (#2848)
* feat: add tags filtering to list_memories / list_memory_units

Add `tags` and `tags_match` parameters to `list_memory_units`,
MCP `list_memories` tool, and HTTP `GET /memories/list` endpoint,
bringing the browse side's tag filtering capability in line with
the write side (`retain`) and semantic search side (`recall`).

The implementation reuses the existing `build_tags_where_clause`
function from `hindsight_api/engine/search/tags.py`, supporting
all five matching modes: any, all, any_strict, all_strict, exact.

Closes #2842
Related: #792

* review fixes: robust prefix strip, exact global scope, tests, regen clients

- Use str.removeprefix("AND ") instead of str.lstrip("AND ") when appending
  the tags clause in list_memory_units (lstrip strips a char set, not a
  prefix — matches the existing idiom used elsewhere in the file).
- Handle tags_match="exact" with no tags: select the untagged/global scope,
  mirroring recall and the sibling list path.
- Type the MCP list_memories tools' tags_match as TagsMatch; document all
  five matching modes in the engine/HTTP/MCP docstrings.
- Add integration tests covering all five modes + exact-empty global scope
  and the no-filter baseline (tests/test_tags_visibility.py).
- Regenerate OpenAPI spec, docs-skill reference, and Python/TS/Go clients.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-21 11:15:40 +02:00
Nicolò Boschi 4754efc419 chore(db): remove the dead advisory_lock dialect helper (#2855)
DatabaseDialect.advisory_lock() had no production callers — the only reference
was a test asserting its output string. It advertised PG advisory locks as a
supported dialect primitive, which contradicts the Database Locking standard
added in #2817 (advisory locks are unusable behind connection poolers / managed
PG). Leaving it invites the next author to reach for it.

Remove the abstractmethod on DatabaseDialect plus the PostgreSQL and Oracle
implementations, and the lone test assertion. The grandfathered raw
pg_try_advisory_lock in migrations.py (the concurrent-migration coordinator) is
unaffected — it never went through this helper.
2026-07-21 10:56:49 +02:00
Jordan-Jarvis a404071d3b fix: remove vulnerable API runtime packages (#2851) 2026-07-21 10:44:45 +02:00
Ben e07ca1bd0a blog: Persistent memory for ZCode (Z.ai GLM coding agent) (#2838)
* blog: persistent memory for ZCode (Z.ai GLM coding agent)

Announcement/how-to for the ZCode integration: hooks-based (no MCP),
recall before each prompt + retain each turn, cross-tool shared bank with
Claude Code and Cursor, per-project isolation, cloud or self-hosted.
Grounded in the merged integration doc, README, and hook source. Cover:
emerald mesh + glassy tool panels with the ZCode mark.
2026-07-20 14:59:33 -04:00
Nicolò Boschi d61e8ffff6 fix(worker): discover expired-operation schemas in one query, default retention off (#2819)
Follow-up to #2708, which bounded terminal `async_operations` history. Two
issues with what landed:

1. The cleanup worker did not use a cross-tenant routine. It opened a connection
   and a prune transaction against *every* tenant schema on every cleanup cycle,
   paying the full per-tenant cost even when nothing was prunable — the query
   storm the server-side maintenance routines exist to avoid.
2. It shipped as a breaking change, silently switching deployments from
   unbounded operation history to a 30-day TTL on upgrade.

Adds `schemas_with_expired_operations(p_days int) RETURNS SETOF text` — the
`async_operations` counterpart to `schemas_with_expired_rows`. One round-trip
returns just the schemas holding expired terminal rows; the worker then acquires
a connection and prunes only there. It needs its own routine rather than reusing
`schemas_with_expired_rows` because eligibility here isn't "row older than N
days" — pending and processing rows are never prunable, so the status filter has
to be part of the predicate.

Install policy follows b6d2f8a4c1e7 (#2638/#2824): the routine is database-global
(it enumerates pg_class across every schema), so exactly one copy is installed —
into the schema this deployment is configured to use, which is the one the worker
calls via fq_routine. Gating on the literal "public" instead of the configured
schema is what left non-public deployments without the sibling routines (#2638);
installing into every schema would leave a dead duplicate per tenant.

Exactly one run satisfies that predicate, so concurrent per-schema runs never
issue competing CREATE OR REPLACE against the same pg_proc row and cannot hit
`tuple concurrently updated`. No cross-process coordination, and in particular no
advisory lock, which is unusable behind connection poolers and managed PG
(#2817). Runs targeting any other schema drop the routine there instead.

The worker calls the routine through schema.fq_routine() (added in #2824) rather
than a hardcoded public. qualifier — duplicating that qualifier across callers is
precisely how #2638 recurs.

Vanishing schemas are skipped rather than fatal (c7e9f1a3b5d2), and an absent
routine degrades cost, not correctness — Oracle and un-migrated PostgreSQL fall
back to the previous full sweep with a warning.

DEFAULT_OPERATION_RETENTION_DAYS 30 -> 0. Operation history is a user-visible
audit trail, so bounding it is an opt-in policy decision rather than something an
upgrade applies silently. Set HINDSIGHT_API_OPERATION_RETENTION_DAYS to a
positive number of days to enable pruning. Docs, .env.example and the bundled
embed template updated to match.

- test_schemas_with_expired_operations — drives the real routine against pg0 in a
  throwaway schema: old pending/processing rows alone don't make a schema
  eligible, a terminal row does, a too-old cutoff doesn't, p_days <= 0 is empty.
- test_expired_operations_routine_installs_in_the_configured_schema —
  parametrized over base / default public / non-public single-tenant; guards
  against reintroducing the #2638 literal gate or an advisory lock.
- test_expired_operations_tenant_runs_install_nothing — tenant runs emit no
  CREATE and drop any copy in their own schema.
- test_discovery_targets_the_configured_non_public_schema — the worker calls the
  copy in its configured schema, not a hardcoded public one.
- TestWorkerOperationCleanupSchemaNarrowing — only reported schemas are pruned,
  nothing expired means no pruning, unclaimed schemas are skipped, a missing
  routine falls back to the full sweep, Oracle never calls the routine.
2026-07-20 18:35:54 +02:00
Nicolò Boschi c20e08fecc fix(retain): make entities a plain list of strings (#2749) (#2830)
The prompt's few-shot examples taught a flat string array while the
LLM-facing schema declared list[Entity] objects. Models that follow the
prompt literally returned strings, so the entities were dropped and
never persisted - entities, unit_entities and entity_cooccurrences all
stayed at 0 while retain reported success and recall kept working.

The Entity model was a single-field wrapper around a string and carried
no information the string didn't, so it is removed rather than taught
to the prompt. entities is now list[str] end to end: the four LLM-facing
extraction models, the labels-only dynamic model, and the storage Fact
model. This matches the API response model (response_models.ExtractedFact)
and the pipeline dataclass (retain.types.ExtractedFact), both already
list[str].

entities stays optional. An omitted field is coerced to an empty list
anyway, so requiring it would only risk strict-schema providers
rejecting otherwise-valid facts.

A shared _coerce_entity_strings before-validator still unwraps the
legacy {"text": ...} form, so responses from models that learned it and
in-flight batch jobs are not lost. The prompt now states the string
contract explicitly in the ENTITIES section.

Tests: a fast schema/coercion suite plus an hs_llm_core test that runs
the real extraction pipeline and asserts entities are populated - the
bug was behavioural, so MockLLM cannot reproduce it. test_entity_labels
is updated for the string representation.

Also stages the pre-existing skills/hindsight-docs regen drift from
main (retain.md, zcode.md), which the pre-commit generator refreshed.
2026-07-20 18:07:12 +02:00
Nicolò Boschi 2142d43f6f test(recall): stop passing removed semantic_seeds into link expansion (#2829)
#2683 removed the graph seed inputs from LinkExpansionRetriever.retrieve() —
Link Expansion deliberately chooses its own bounded seeds so it doesn't inherit
the semantic arm's limits and thresholds. The scoring regression test from #2679
still passed semantic_seeds=, so it fails on main with

    TypeError: retrieve() got an unexpected keyword argument 'semantic_seeds'

on every PR whose test-api shard includes it.

Drop the kwarg and stub the internal _find_semantic_seeds lookup instead, which
is where seeds now come from. The test's subject — that the graph merge order
matches Link Expansion's additive per-type score — and all of its assertions are
unchanged.

The skills/hindsight-docs hunk is generated output from an unrelated docs PR that
landed without regenerating the bundle; the pre-commit generator requires it.
2026-07-20 18:07:03 +02:00
Nicolò Boschi 0c38d46ee9 feat(pg0): carry optional user/password in pg0:// URLs (#2832)
Extend the embedded-database URL syntax to
`pg0://user:pwd@instance:port` (either credential half optional).
Previously every pg0 instance was forced to the hardcoded
`hindsight`/`hindsight` credentials because the URL parser only
carried instance name and port; `EmbeddedPostgres` already accepted
username/password, they just weren't threaded through.

`parse_pg0_url` now returns a `Pg0Url` dataclass instead of a
3-tuple (clears the multi-item tuple return, matches the recent
dataclass refactor) and `resolve_database_url` passes credentials
through only when present, so omitting them keeps the pg0 defaults.
Credentials split on the last `@` so passwords may contain `@`.
2026-07-20 17:55:23 +02:00
Justas Šireika 375ec091f3 fix(db): re-apply session GUCs on pool acquire via asyncpg setup= (#2815)
asyncpg runs RESET ALL on connection release, so the session GUCs the
init callback SET (hnsw.ef_search and the other ANN tuning knobs,
statement_timeout) were wiped after a connection's first release. Every
subsequent recall on a reused connection ran at pgvector defaults
(ef_search=40), silently degrading recall quality. Pass the same
init_callback as setup= so it re-applies on every acquire, after the
reset.
2026-07-20 17:43:06 +02:00
ijevinandijevin eb5b29f067 fix(retain): make lazy bank creation atomic (#2695) (#2802)
Co-authored-by: ijevin <[email protected]>
2026-07-20 17:42:10 +02:00
handnewb 000fb9ddbe fix(audit): add missing @audited decorator to api_update_memory (#2798)
The PATCH /memories/{memory_id} endpoint (curate/invalidate/revert)
was the only data-mutation endpoint without an audit trail. All other
mutation endpoints (delete_memory, update_document, delete_document,
create_mental_model, etc.) have @audited decorators.

This ensures memory curation operations are recorded in the audit log
for compliance and forensic traceability.

Found during cybersecurity audit.
2026-07-20 17:26:13 +02:00
handnewb dfa02c8b61 fix(retain): reject degenerate fact text before storage (#2520) (#2794)
* fix(retain): reject degenerate fact text before storage

Facts with zero information content (empty strings, punctuation-only,
LLM hallucination patterns like '...', '-', '--') were being stored,
indexed, and surfaced in recall results. This adds a content quality
guard in ProcessedFact.from_extracted_fact() that rejects degenerate
text before it enters the storage pipeline.

Closes #2520

* chore: ruff format + fix import ordering in types.py
2026-07-20 17:06:29 +02:00
BenandNicolò Boschi d28b852732 fix(query-analyzer): pick strongest dateparser match, not the leftmost (#2768) (#2772)
dateparser.search_dates over-matches: short common words that are weekday
or month abbreviations in some language ("we"/"me"/"did" resolve to a
weekday, "do" to Sunday) come back as bogus dates. The analyzer took the
first valid match, so when a false positive appeared before the real date
the query got a plausible-but-wrong temporal window — worse than none,
since the constraint is non-null and nothing downstream can tell that
extraction failed.

The previous defence was a hard-coded blacklist of such words, which is a
moving target (every short word dateparser resolves is a new instance of
the same bug) and was already partly dead code: the `len(text) > 3` escape
hatch re-admitted every multi-character entry, so only the <=3-char words
did any work. The bug also depends on the dateparser version — 1.4.1 (the
version shipped in the published image) added "we" as an English Wednesday
abbreviation that survives `languages=["en"]` scoping, while the locked
1.2.2 does not — so language scoping is not a stable fix either.

Replace the blacklist + leftmost selection with a signal score: each match
is scored by the date content it actually carries (a digit is strongest,
then explicit month/relative words, then weekday/period words). Matches
with no signal (bare abbreviations) score zero and are rejected; among the
rest the strongest wins, ties broken by longest span. This subsumes the
entire blacklist and is independent of language and dateparser version.

Tested (Friday reference date, where these abbreviations resolve):
- "what did we discuss"                   -> no constraint (was 07-12/07-15)
- "tell me what we decided on 2026-06-10" -> 2026-06-10 (was 07-15)
- "what did we discuss in May"            -> May (unchanged, now robust)

Regression tests assert analyzer output, never raw dateparser spans, so
they hold across dateparser versions.

Co-authored-by: Nicolò Boschi <[email protected]>
2026-07-20 17:05:15 +02:00
Ben 6e03dd2d4c release(zcode): v0.1.0 2026-07-20 11:03:45 -04:00
Ben b11e053323 feat(zcode): add Hindsight long-term memory integration for ZCode (#2549)
* feat(zcode): add Hindsight long-term memory integration for ZCode

Adds a hooks-based, no-MCP integration for ZCode (Z.ai's GLM desktop
coding agent). ZCode embeds the Claude Code agent runtime and reads the
standard Claude Code hook schema from its own config namespace
(~/.zcode/cli/config.json), so `hindsight-zcode install` wires three
process hooks — SessionStart, UserPromptSubmit (recall), and Stop
(retain) — without touching the user's ~/.claude config and without an
MCP server.

Recall injects relevant memories as additionalContext before each
prompt; retain assembles each turn from the prompt (captured at
UserPromptSubmit) and the response (Stop payload) and stores it to
Hindsight. Verified end-to-end in ZCode 3.2.2: hooks fire, retain
persists to the cloud bank, and recall injects memory into the agent.

Includes the pip package + installer, hook scripts, tests, CI job,
release-integration wiring, changelog registration, docs page, and
gallery entry.

* feat(zcode): add self-serve marketplace + hooks-only plugin variant

Publishes the ZCode integration as a hooks-only Claude Code plugin
(hindsight-zcode) in the repo's plugin marketplace, so ZCode users can
install it via 'zcode plugins add-marketplace vectorize-io/hindsight'
without pip and without depending on Z.ai's marketplace.

The plugin reuses the pip package's hook scripts via CLAUDE_PLUGIN_ROOT
(no duplication) — settings.json resolves as a sibling of scripts/ in
both the pip and plugin layouts. Adds a plugin manifest, plugin-format
hooks.json (SessionStart/UserPromptSubmit/Stop — no SessionEnd),
marketplace entry, validation tests, and docs.

* fix(zcode): drop changelog link from docs page (page exists only after release)

The /changelog/integrations/zcode page is generated at release time, so
linking to it broke the Docusaurus build (build-docs + verify-generated-files).
Most unreleased integration pages omit this link; follow that convention.
2026-07-20 10:58:24 -04:00
dimonnld 11154d48b7 Fix day+month+year dates collapsing to the whole month (#2791)
extract_period() runs before dateparser and matches "<month> <year>", so
"meeting on 13 July 2024" was widened to 2024-07-01..2024-07-31 and the day
was lost. Skip the month-table match when a day number precedes the month,
letting dateparser resolve the exact date instead.

Language-agnostic: affects every language in the period table (English shown
in the test). Split out of #2767 per review so the correctness fix can land
independently of the Russian-coverage change.
2026-07-20 15:02:08 +02:00
Sanjay Santhanam a483682da6 fix(reflect): cap done tool answers (#2757)
Apply the configured max_tokens budget when the reflect agent finishes through the done tool. Add a regression test covering the previously uncapped completion path.
2026-07-20 14:39:12 +02:00
Jordan-Jarvis 8a7a70b828 feat(api): attribute remote reranker calls by bank (#2740)
* feat(api): attribute remote reranker calls by bank

* fix(api): omit empty reranker bank attribution

* fix(reflect): bind bank attribution for tool calls
2026-07-20 14:32:08 +02:00
Sanderhoff-alt dddd571a99 fix(ci): avoid rebuilding docs in verify-generated-files (#2739)
Run the existing build-docs job for every PR so the production docs
build remains an unconditional check.

Generate OpenAPI directly in verify-generated-files to avoid rebuilding
the Docusaurus site serially in that job.
2026-07-20 14:30:32 +02:00
Jordan-Jarvis 8bd9ce194b fix(migrations): preserve percent-encoded database URLs (#2733)
* fix(migrations): preserve percent-encoded database URLs

* fix(style): restore migration file newlines
2026-07-20 14:28:29 +02:00
Jordan-Jarvis 0e0fd14ed4 fix(reflect): preserve required tools for custom OpenAI endpoints (#2734)
* fix(reflect): preserve required tools for custom OpenAI endpoints

* fix(style): format required-tool regression
2026-07-20 14:27:42 +02:00
Nicolò Boschi 946a80bfb8 fix(engine): isolate operation completion from best-effort side-effects (#2823)
execute_task completes an operation via _mark_operation_completed /
_mark_operation_completed_and_fire_webhook, both of which wrapped the
status='completed' commit in one transaction with fallible side-effects
(webhook outbox insert, parent aggregation) and swallowed every exception.
A hiccup in either rolled the completion back and dropped the error, leaving
the operation stuck in 'processing' forever while the log already said the
work was done (#2601). PR #2608 added a poller-side backstop that unstuck
the row but silently lost the consolidation webhook.

- On failure of the atomic outbox transaction, fall back to a completion-only
  commit and fire the consolidation webhook best-effort (non-transactional)
  instead of losing both. Happy path keeps the transactional-outbox guarantee;
  the failure path degrades to completed + delivered rather than stuck + lost.
  The best-effort fire only runs when the fallback actually transitioned the
  row, so there is no duplicate delivery.
- Guard every completion UPDATE on `status NOT IN ('completed','failed',
  'cancelled')` so an already-terminal row is never re-terminalized: keeps the
  engine idempotent with the poller backstop (#2608) and avoids double parent
  aggregation, while still completing pending/processing rows.

Adds fast DB-free regression tests (fake connections) covering the happy
path (no double-fire), the webhook-failure fallback, and the terminal-row
no-op guard.
2026-07-20 14:25:45 +02:00
Nicolò Boschi 07af5b4a37 fix(migrations): install the maintenance routines once, in the configured schema (#2824)
Follow-up to #2820, which fixed #2638 the wrong way.

The three discovery routines are database-global: each enumerates pg_class across
every schema and dispatches per schema, and the maintenance loop only ever calls
the copy in get_config().database_schema. #2820 installed a copy into every
schema the migration touched, so a 20k-tenant database ended up with 20k copies
of each routine, 19,999 of which are never invoked — catalog garbage, and a
global function nonsensically duplicated per tenant.

The actual #2638 bug was never the gating; it was the hardcoded literal. The old
predicate compared target_schema against "public" instead of against the schema
the deployment is configured to use, so a single-tenant install living in a
dedicated non-public schema never matched and got no routines at all.

Compare against get_config().database_schema instead. Exactly one run satisfies
the predicate, so exactly one copy is installed, in the schema fq_routine()
actually calls. That still avoids the concurrent CREATE OR REPLACE the gate
existed for — no two runs touch the same pg_proc row — with no cross-process
coordination and no advisory lock (#2817).

Runs targeting any other schema now DROP the routines there rather than merely
skipping, so databases that already ran #2820 shed their per-tenant duplicates on
the next migration pass instead of carrying them forever.

Also moves the qualifier helper from maintenance._routine to schema.fq_routine.
It sits beside fq_table/fq_table_explicit, and the worker poller needs it too
(#2819) — a second caller open-coding the qualifier is exactly how #2638 recurs.

The skills/hindsight-docs one-line change is generated output, not authored here:
the docs-skill bundle was left unsynced by the PR that added
HINDSIGHT_API_DB_MAX_PARALLEL_WORKERS_PER_GATHER, and the pre-commit generator
refuses to commit without it.

Tests: the install test is re-parametrized over (target_schema, configured
schema) including the non-public single-tenant shape; a new test asserts tenant
runs install nothing and drop strays; downgrade tests are keyed on the configured
schema rather than the literal public.
2026-07-20 14:09:36 +02:00
BenandClaude Opus 4.8 59b008a461 docs(retain): correct entity resolution — no nickname resolution (#2694)
Entity resolution is fuzzy name matching (SequenceMatcher) reinforced by
co-occurrence and temporal proximity — there is no nickname/alias logic in
the resolver. Dissimilar names like 'Bob' and 'Robert Chen' do not unify on
the name alone, so the 'nickname resolution' example was inaccurate. Verified
against hindsight-api-slim/hindsight_api/engine/retain/entity_resolver.py.

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-20 14:01:19 +02:00
Sanderhoff-alt 188f8fcb32 fix(engine): consolidate duplicated engine definitions (#2691)
Centralize causal taxonomy and transfer history table definitions.
Use shared canonical type in import; remove the unused seed.
2026-07-20 14:00:52 +02:00
Sanderhoff-alt d7059d2840 fix(recall): remove unused graph seed inputs (#2683)
Graph retrieval always selects its own bounded semantic seeds.

Remove the unused semantic_seeds and temporal_seeds inputs from
the graph retriever interface and link-expansion implementation.
The recall orchestrator no longer passes placeholder None values.

Document why graph seeds stay independent: the semantic and
temporal retrieval arms use different candidate limits and thresholds,
so reusing them would silently change graph recall behavior.

Add a regression assertion that the graph call contains no removed
seed inputs.
2026-07-20 13:59:59 +02:00
Bruce HicksandClaude Fable 5 5adaf60a9f feat(anthropic): carry the prompt-cache marker on batch system prompts (#2652)
Follow-up to #2628 + #2629: the batch path sent system as a plain string,
so batch requests never participated in prompt caching. Batch items are
one-shots, so this applies call()'s one-shot rule — system is the sole
cache breakpoint, rendered via the same _cached_system_blocks helper.
Every request in a retain batch shares the fact-extraction system prompt,
so the first item's cache write serves the remaining items as best-effort
reads, and the cache-read discount stacks with the 50% batch discount.
No end-marker on batch messages: that breakpoint only pays off on the
sync tool loop, where the next iteration reads it back.

Tests: cached-block wire shape (marker present, messages unmarked),
schema injection lands inside the cached block, no-system requests
unchanged; existing shape assertions updated from string to block list.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-20 13:50:31 +02:00
Nicolò Boschi 3fb33b9873 fix(trace): gate LLM trace writes on backend lifecycle, not error strings (reverts #2618) (#2821)
* Revert "fix(trace): skip LLM trace writes during daemon shutdown/pre-init races (#2618)"

This reverts commit cb4fe70b63.

* fix(trace): gate LLM trace writes on backend lifecycle, not error strings

The reverted #2618 classified shutdown/pre-init races by matching on
exception text ("pool is closing", "not initialized") and by reaching into
`backend._pool`. Both are PG/asyncpg-specific: Oracle raises different
messages, and any new backend or pool wrapper silently loses the guard —
while a genuine "not initialized" error from elsewhere gets swallowed.

Make the lifecycle state explicit instead, and close the race at the source:

- `DatabaseBackend.is_ready` — an abstract property both backends implement
  (`_pool is not None`), replacing the internals peek.
- Both `shutdown()` implementations drop the pool reference *before* awaiting
  close(), so is_ready is False for the whole teardown rather than only after
  it. That is the window that produced "pool is closing".
- `LLMTraceRecorder.close()` stops accepting writes and drains in-flight ones;
  `MemoryEngine.close()` calls it before `backend.shutdown()`, so trace tasks
  can no longer outlive the pool. Metadata patches are now tracked too (they
  were fire-and-forget and untracked).
- Both write paths skip via a single `_writable()` check. No error-string
  matching: a failure on a ready backend is still a WARNING, as it should be.

* simplify: drop the recorder drain, keep the readiness check

The drain (recorder close() + task tracking + engine wiring) duplicated work
the pools already do: asyncpg's close() waits until all connections are
released, so a trace INSERT that already acquired completes on its own. The
readiness check plus dropping the pool reference before the awaited close
covers both windows that actually produced warnings.
2026-07-20 13:47:49 +02:00
Bruce HicksandClaude Opus 4.7 ca97f947d9 feat(api): enrich refresh_mental_model result_metadata with semantic outcome (#2605) (#2627)
refresh_mental_model operations completed with result_metadata carrying only
the submit-time {mental_model_id, name} stub — set before the op ran and never
enriched — so a monitoring layer could not distinguish "refreshed with real
content" from "refreshed empty" without a follow-up content fetch. Retain
operations have carried machine-readable outcome metadata since 0.8.x.

Mirror the retain pattern: the worker handler now merges the semantic outcome
into result_metadata at completion (jsonb ||, preserving the submit-time keys
consumers join on):

- content_len: length of the final stored content
- populated_content: true only for real synthesis — the "No answer provided."
  reflect fallback and the "Generating content..." placeholder complete
  wire-successful but read as false (a bare length check would miss them)
- based_on_counts: per-fact-type grounding counts from the reflect response

The reflect agent's fallback literal is promoted to NO_ANSWER_TEXT so the
populated judgment compares against the constant, not a copied string.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-07-20 13:43:07 +02:00
Chris Bartholomew 347b9c23c4 feat(config): optional cap on planner parallelism for pool connections (#2600)
Adds HINDSIGHT_API_DB_MAX_PARALLEL_WORKERS_PER_GATHER — when set, every
pool connection of the process runs SET max_parallel_workers_per_gather
at init time (alongside the existing statement_timeout / ANN tuning
session setup). Unset (the default) leaves the server setting untouched,
so existing deployments see no behavior change.

Motivation: in multi-tenant deployments where background workers share a
database with latency-sensitive foreground traffic, bulk maintenance
queries (consolidation, graph upkeep) can fan out across parallel
workers and occupy several cores each. Parallelism buys latency — which
background work doesn't need — at the cost of concurrent CPU footprint,
which a shared primary does care about. Setting the cap to 0 on worker
processes makes those queries run serially: measured on a representative
multi-million-row aggregate, serial execution cost ~29% more wall-clock
but used 67% fewer concurrent cores (and less total CPU, since parallel
coordination isn't free).

0 is a meaningful value (disable parallelism), so the env parse
distinguishes unset (None, no opinion) from 0 via a new
_parse_optional_non_negative_int helper; negative or non-integer values
fail fast at startup.

The field is static (process-level infrastructure tuning), deliberately
not in _CONFIGURABLE_FIELDS.
2026-07-20 13:40:44 +02:00
Nicolò Boschi ebe438b25b style: apply ruff format to reflect/agent.py (#2822)
A long call in _generate_structured_output exceeds the 120-char line limit and
was committed unformatted, so ruff format rewrites it on every CI run. That
fails verify-generated-files ('Generated files are out of sync') on every
hindsight-api-slim PR, none of which touch this file.

Formatting only — no behaviour change.
2026-07-20 12:52:24 +02:00
c05ca6529f perf(graph-maintenance): prune stale cooccurrences via INTERSECT, not a self-join (#2473)
The staleness predicate in prune_stale_cooccurrences used a correlated
`unit_entities u1 JOIN u2 ON u1.unit_id = u2.unit_id` self-join. The planner
turns that into a Nested Loop Anti Join whose hash side rebuilds a
high-degree entity's entire membership set once per cooccurrence pair, so
cost scales with hub_degree * pairs even when zero rows are stale.

Replace it with an INTERSECT of the two entities' unit sets. Both branches
resolve as Index Only Scans on idx_unit_entities_entity_unit
(entity_id, unit_id), bounding per-pair cost by the two entities' degrees.

Measured on a hub-skewed fixture (40K-membership hub, 2999 live pairs,
zero deletions -- the worst case), against the current ordered-locking CTE:

  self-join   18182 ms   73,613,239 shared buffers
  INTERSECT    2555 ms      255,045 shared buffers

7.1x faster, 289x fewer buffers. Production banks carry ~260K pairs, so the
gap there is wider. No schema change; the index already exists (h3i4j5k6l7m8).

The #2529 ordered-locking CTE is untouched -- the rewrite is confined to the
NOT EXISTS predicate inside it, so victims are still selected FOR UPDATE in
sorted (entity_id_1, entity_id_2) order.

Co-authored-by: Nicolò Boschi <[email protected]>
Co-authored-by: Sergey <[email protected]>
2026-07-20 12:38:53 +02:00
Nicolò Boschi 6a6d4f2261 fix(migrations): install maintenance routines into each run's own schema (#2820)
The three cross-tenant discovery routines that drive the background maintenance
loop — banks_needing_consolidation(), schemas_with_expired_rows(...) and
mental_models_with_cron() — were installed into public and gated on the run
being the base run or an explicit target_schema='public' run.

A single-tenant deployment migrated into a dedicated non-public schema
(HINDSIGHT_API_DATABASE_SCHEMA=<non-public>) migrates only that one schema, so
the gate never opens and no routine is ever created. The loop then logs
'function public.… does not exist' every cycle, and the revision is stamped
applied so redeploying does not help. #2056 fixed only the public/base-run case.

Fix: stop putting them in a shared schema. Migration b6d2f8a4c1e7 installs all
three into the run's own target_schema, unconditionally, and maintenance.py
qualifies its calls with get_config().database_schema instead of a hardcoded
'public.'. Where a routine lives does not affect what it returns — each
enumerates pg_class across the whole database and dispatches per schema — so the
copy in the configured schema is fully functional, and that schema is by
definition one that got migrated.

This also removes the concurrency hazard the old gate existed to dodge rather
than locking around it: each process only ever writes CREATE OR REPLACE FUNCTION
"<its own schema>".fn(), so two concurrent per-schema runs never contend on the
same pg_proc row and 'tuple concurrently updated' cannot occur. No cross-process
coordination is needed — in particular no advisory lock, which is unusable here
(see the revert of #2690). Cost is one duplicate routine per tenant schema: a
few catalog rows, and the price of needing no coordination.

Existing broken installs self-heal — the revision runs on every schema and
creates the routine exactly where that deployment's loop looks for it. Default
public deployments are unaffected. Function bodies are byte-identical to
c7e9f1a3b5d2 / f4d1c2b3a5e6. PG-only, mirroring e5f6a7b8c9d0.

Tests: a parametrized unit test asserting the install runs for every
target_schema (and that neither the public-only gate nor an advisory lock comes
back), plus an end-to-end pg0 test that drives a per-schema run into a real
non-public schema and calls the resulting routine.

Fixes #2638
2026-07-20 12:28:56 +02:00
Nicolò Boschi cf7aece729 revert(migrations): drop advisory-lock maintenance-routines install (#2690) (#2817)
#2690 added migration f2a4b6c8d0e2, which installs the shared public.*
maintenance routines on every PG run and guards the resulting concurrent
CREATE OR REPLACE with a blocking pg_advisory_xact_lock.

Advisory locks are not usable in Hindsight: deployments sit behind connection
poolers and managed/PG-compatible services where they are unreliable or
unsupported — a session-level lock can leak or vanish when the pooler reassigns
the session, and a blocking acquire can wait on a grant that never comes. That
holds for transaction-scoped locks too, so the migration has to go rather than
be tuned.

f2a4b6c8d0e2 is not in any core release (v0.8.4 predates it), so it is removed
outright and a8c1e4f7b0d3 is re-pointed at e7c3a9f1b2d5. Single head preserved
(a8c1e4f7b0d3, 86 revisions). The #2690 unit test is removed with it; the rest
of tests/test_maintenance_routines.py passes against the shortened chain.

Also codify the ban in .claude/skills/code-review/SKILL.md: a Database Locking
standard plus review step 11c, both pointing at the alternatives (per-process
objects, idempotent DDL, row-level constraints) instead of locking.

This reopens #2638 (maintenance routines never installed when the deployment
uses a non-public schema); a lock-free fix follows in a separate PR.
2026-07-20 12:15:41 +02:00
36e94454e6 fix(control-plane): clear mental-model tags when the edit field is emptied (#2507) (#2508)
The mental-model edit dialog sent `tags: tags.length > 0 ? tags : undefined`,
so clearing the tags field made the key drop out of the PATCH body
(JSON.stringify omits undefined). The dataplane treats an absent `tags`
field as "unchanged" (`if tags is not None` in `update_mental_model`), so
the previous tags survived and refreshes kept filtering by them — the only
workaround was delete + recreate.

Always send the `tags` array, including the empty array, so emptying the
field sends `tags: []` and the backend clears them.

Co-authored-by: caddi-ci-cd <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-20 10:56:28 +02:00
Nicolò Boschi 5bfef3caa4 revert(docs-skill): drop cookbook pages from the docs skill bundle (#2818)
#2649 added both cookbook pages and per-integration docs to the
generated docs skill. Keep the integration docs; remove the cookbook.

- drop the cookbook tree walk and the CookbookGrid MDX renderer from
  generate-docs-skill.sh
- drop cookbook paths from the generated SKILL.md index
- regenerate the bundle (28 cookbook files removed)
2026-07-20 10:53:47 +02:00
Parafee41 c1908a0205 docs(cli): describe explore memory detail view (#2497) 2026-07-20 10:53:19 +02:00
Evo 54354e4735 fix(reflect): thread max_completion_tokens into the structured-output extraction call (#2431) (#2486)
* fix(reflect): thread max_completion_tokens into structured-output extraction

#2433 capped the structured retry budget but the structured second pass never
received an output-token budget, so on reasoning/preamble models the provider
default is exhausted before JSON is emitted (finish_reason=length, empty
content) and structured reflect degrades to None. Thread the reflect max_tokens
through _generate_structured_output (and _process_done_tool) as
max_completion_tokens, mirroring the plain reflect calls. Fixes #2431.

* test(reflect): cover structured-output max_completion_tokens threading
2026-07-20 10:51:46 +02:00
Srujan rai 4df4b398f5 fix(search): include proof_count in temporal spreading SQL SELECT (#2479)
The LATERAL join query for temporal graph spreading omitted mu.proof_count
from the SELECT list. RetrievalResult.from_db_row() calls row.get("proof_count"),
which always returned None for spread neighbors, forcing a neutral 0.5
proof-count boost regardless of actual observation evidence strength.
2026-07-20 10:46:09 +02:00
Nicolò Boschi 81aa4979b3 feat(reflect): step-by-step context caching for the Gemini tool loop (#2540)
Roll a CachedContent forward through the reflect tool loop so each auto turn reuses the entire prior conversation at the cached-input rate and sends only its new tool results. Measured on gemini-2.5-flash-lite: ~29% cached on short loops, ~74-81% on deep loops (deepest turns ~99%), vs ~9% for the old static prefix and 0% for implicit caching.

Cache creates overlap tool execution to hide their latency, and the ephemeral per-reflect caches are torn down detached so the response path never waits on deletes. New HINDSIGHT_API_REFLECT_PROMPT_CACHE_ENABLED flag (default true) disables it independently of the global prompt cache.
2026-07-20 10:45:00 +02:00
Sanderhoff-alt 0108cd7019 chore: remove stray local state files (#2472) 2026-07-20 10:41:58 +02:00
Sanderhoff-alt aad0af9756 feat(auth): add create bank validation hook (#2395)
Add a precise operation-validator hook for bank creation, with a
no-op default so deployments without custom validators keep existing
behavior.

Route lazy bank creation through the hook from retain, imports, MCP
create_bank, and the default get_bank_profile auto-create path. This
keeps create-bank authorization separate from bank-scoped write
validation, which often assumes the target bank already exists.

Add regression coverage for rejected creation, existing-bank skips,
HTTP create/import paths, async retain, profile auto-create, and MCP
create_bank.
2026-07-20 10:40:01 +02:00
Sanderhoff-alt bd49f6a7c7 fix(mcp): prevent get_bank from creating banks (#2393)
Treat the get_bank MCP tool as read-only by looking up bank profiles
without auto-creation in both single-bank and multi-bank modes.

Add regression coverage for missing banks so get_bank returns a
not-found error instead of creating the bank.
2026-07-20 10:38:34 +02:00
Nicolò Boschi 263eba1342 fix(embeddings): truncate oversized litellm-sdk inputs before embedding (#2501) (#2516)
Mental-model content in delta-refresh mode can grow past an embedding
model's fixed input-token limit (e.g. Bedrock Titan V2's hard 8192 cap),
after which every refresh fails permanently with ContextWindowExceededError
and no recovery path.

Add an opt-in `HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS` cap.
When set, `LiteLLMSDKEmbeddings.encode()` truncates each input to that many
cl100k_base tokens before calling litellm.embedding(), mirroring the existing
reranker `max_tokens_per_doc` pattern. Truncation emits a log.warning naming
the model and largest original token count so it isn't silent.

Off by default (no behavior change / data loss for large-context models);
Titan users set it to the model's real limit with a little headroom.
2026-07-20 10:17:58 +02:00
418524051d perf+fix(graph-maintenance): catch the #2529 sweep deadlock in continuous perf, and drive dropped passes to zero (#2534)
* fix(graph-maintenance): retry cooccurrence sweep on deadlock

prune_stale_cooccurrences/prune_orphan_entities scan entity_cooccurrences
via a join/NOT EXISTS plan with no consistent lock-ordering guarantee,
while retain's concurrent cooccurrence upserts (entity_resolver) lock the
same rows in sorted (entity_id_1, entity_id_2) order. When the sweep and a
concurrent upsert touch overlapping rows in opposite orders, Postgres
detects a genuine cycle and aborts one side with DeadlockDetectedError —
this was 39 of 41 DeadlockDetectedError occurrences in a week of
self-hosted production logs.

Both prunes are idempotent bank-wide deletes, so wrap the sweep in the
existing retry_with_backoff helper (already deadlock-aware, previously
only used internally by acquire_with_retry's legacy pool path) instead of
letting a transient deadlock drop the maintenance pass entirely.

Adds a raw two-connection reproduction of the deadlock plus a test that
the sweep now survives one transient DeadlockDetectedError and still
returns correct prune counts.

Co-authored-by: Cursor <[email protected]>

* perf(graph-maintenance): add contention suite that catches the #2529 sweep deadlock

The existing graph-maintenance suite runs run_graph_maintenance_job in
isolation, so its Pass 2/3 cooccurrence sweep never overlaps a concurrent
writer and can never deadlock — which is why continuous perf never caught
#2529. The new graph-maintenance-contention suite drives prune_stale_cooccurrences
against retain-shaped sorted cooccurrence upserts and gates on the deadlock
escape rate (dropped/observed): ~100% unprotected (fails), ~0% with the
retry_with_backoff fix (passes).

* fix(graph-maintenance): jittered backoff + larger sweep retry budget so deadlocks stop dropping passes

Completes #2529. The retry wrap alone still let ~14% of sweep deadlocks
escape under sustained retain contention (perf suite, small scale): the
backoff was deterministic (concurrent retriers woke in lock-step and
re-collided) and capped at 3 attempts.

- db_utils.retry_with_backoff: add equal-jitter to the backoff delay so
  contenders that deadlock together don't retry in sync (benefits every
  retrier, incl. the legacy acquire path). Covered by a new pure-function
  unit test.
- graph_maintenance: give the idempotent Pass 2/3 sweep a larger retry
  budget (8) — it's background work with no client waiting, so a longer
  jittered tail beats dropping a pass and leaking stale graph rows.

graph-maintenance-contention perf suite now measures 0% escape (0 dropped)
at small and medium vs ~100% unfixed; sweep_workers capped at 2 (prod
dedups to one maintenance job per bank, so 3+ concurrent sweeps was an
unfaithful amplifier).

* fix(graph-maintenance): prevent the #2529 sweep deadlock at the source via ordered locking

Prototype: instead of only retrying the deadlock, eliminate the lock-order
inversion that causes it. prune_stale_cooccurrences selects its victim rows in
the same sorted (entity_id_1, entity_id_2) order retain's cooccurrence upsert
locks them (a materialised FOR UPDATE CTE puts LockRows above the Sort), then
deletes the already-locked rows. Same lock order on both sides => no cycle.

- ops_postgresql: ordered-lock CTE prune. PG only — Oracle's DELETE can't carry
  the CTE the same way, so it stays on the ORA-00060 retry path (documented).
- system_perf contention suite: hollow-run guard re-keyed on workloads running
  (upserts+sweeps>0) not deadlocks>0, so a source-level fix (0 deadlocks) passes;
  escape-rate denominator now max(observed,dropped).

Verified (small): 0 deadlocks either side, 0 dropped, 200 upserts + 336 sweeps
concurrent, 10s vs ~30s retry path; full-revert regression still FAILs 100% escape.

* refactor(graph-maintenance): replace tuple/dict returns with dataclasses (code-review)

- _run_sweep returned a bare tuple[int, int] (from #2529's base commit); the
  project bans multi-item tuple returns even for private fns. Return a small
  _SweepCounts dataclass instead.
- contention suite's shared counters were a raw dict with known keys; convert to
  a _ContentionCounters dataclass, matching the file's existing style
  (_GraphMaintTimers). No behaviour change; 18 graph-maintenance tests + perf
  smoke (0 deadlocks, prevented-at-source) still green.

---------

Co-authored-by: Jordi Gil <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-07-20 10:03:05 +02:00
Ben 327aa05e80 blog: The Fully Open Agent Memory Stack (Hermes + Hindsight) (#2771)
* blog: The Fully Open Agent Memory Stack (Hermes + Hindsight)

Grounded technical piece: every layer of a Hermes + Hindsight stack is
open source and self-hostable (open-weights model via vLLM/llama.cpp,
MIT Hermes Agent, MIT Hindsight with local embeddings/reranker/LLM and
no external calls). Includes wiring, honest caveats (64K context,
auto-hook version gate, model license differences), and when it matters.

* blog: recommend gpt-oss-20b, add leaderboard + real M3 Max run

Address review: pivot the model recommendation from the Hermes model to
gpt-oss-20b (trendy, Apache-2.0, 128K, native tools, ~13GB) and explain
why a 'small' Kimi does not actually fit a laptop. Add a 'which model
for Hindsight' section citing the published model leaderboard (gpt-oss-20b
tops retain), and a 'does it fit on a laptop' section with real numbers
from running the full stack on an M3 Max (retain ~8s, recall ~0.6s).
Update cover model panel to gpt-oss-20b.
2026-07-17 15:05:50 -04:00
Ben 9bf0023163 docs(guides): add 44 integration memory guides (#2778)
Add per-integration guides under hindsight-docs/guides/, each with a
hero cover image following the existing guide template.

33 setup guides ("Add <Tool> Memory with Hindsight") for integrations
that had none: aider, ag2, agent-framework, agno, autogen,
claude-agent-sdk, cline, composio, continue, cursor, cursor-cli, dify,
eliza, flowise, gemini-spark, github-copilot, google-adk, grok-build,
haystack, litellm, n8n, nemoclaw, obsidian, omo, openai-agents,
openhands, roo-code, superagent, vapi, windsurf, zapier, zcode, zed.

11 distinct-angle guides for integrations that already had a setup
guide (each cross-links the existing setup guide instead of repeating
install): agentcore (cross-session strategy), codex (per-repo bank
strategy), crewai (shared crew memory), langgraph (state vs long-term),
llamaindex (beyond RAG), opencode (team shared banks), paperclip
(shared across agents), pipecat (voice memory across calls), pydantic-ai
(type-safe async memory), smolagents (memory across runs), strands
(per-agent vs shared banks).

Each guide is grounded in the integration's docs-integrations page and
its README/source.
2026-07-17 15:01:40 -04:00
Derek Bouius d64921221e chore(deps): bump mcp to 1.28.1 (security) (#2782)
Clears 20 high-severity Dependabot alerts for the MCP Python SDK across the
root lock and five integration locks (integration-tests, claude-agent-sdk,
crewai, openai-agents, strands):

  GHSA-jpw9-pfvf-9f58  HTTP transports serve session requests without
                       verifying the authenticated principal   (patched 1.27.2)
  GHSA-hvrp-rf83-w775  experimental task handlers let any client access/
                       cancel other clients' tasks              (patched 1.27.2)
  GHSA-vj7q-gjh5-988w  WebSocket server transport lacks Host/Origin
                       validation                               (patched 1.28.1)

1.28.1 clears all three. mcp is a direct dep in hindsight-integration-tests
and claude-agent-sdk (mcp>=1.0.0) and transitive elsewhere; the locks just
pinned older versions (1.23.3–1.27.1). crewai jumped the furthest (1.23.3),
which pulled newer pydantic/pydantic-core graph edges — its tests still pass.

Not included here: mcp is not part of any Dependabot group PR, so this is
the sole coverage for these alerts. nltk (llamaindex/pipecat) and torch are
handled by the Dependabot uv-group PR #2780.

Verified: claude-agent-sdk 76 passed, crewai 35 passed; lint clean.
2026-07-17 11:57:28 -04:00
Derek Bouius 7bb3d1925b chore(deps): bump pydantic-settings, transformers, soupsieve (security) (#2727)
Clears the pydantic-settings Dependabot alert across all affected
manifests plus the three high-severity alerts in the root lock.

  pydantic-settings 2.12.0/2.14.0/2.14.1 -> 2.14.2  GHSA-4xgf-cpjx-pc3j
  transformers      5.3.0  -> 5.12.1               GHSA-fgcw-684q-jj6r
  soupsieve         2.8    -> 2.8.4                GHSA-2wc2-fm75-p42x
                                                   GHSA-836r-79rf-4m37

pydantic-settings is transitive everywhere (no direct declaration), so
the locks are the only lever. crewai is deliberately left at 2.10.1: the
advisory's range is >=2.12.0,<2.14.2 and NestedSecretsSettingsSource did
not exist in 2.10.x, so it is unaffected.

transformers is a direct dep, and hindsight-api is published, so the
declared floor -- not our lock -- is what protects installers of the
local-ml/local-onnx extras. The old >=4.53.0 floor resolved to 4.57.6
(vulnerable) under any downstream cap of transformers<5, so raise it to
the advisory's first patched version. Note this now fails resolution for
consumers pinned below transformers 5 rather than silently installing a
vulnerable build. The >=4.53.0 floor was already unreachable in practice:
4.53.0 requires tokenizers<0.22, which our own cap excludes.

The tokenizers<=0.23.0 cap is kept. #2055 was caused by transformers
declaring a wider tokenizers range in metadata than its import-time check
enforces, and the cap is what blocks that; the comment now records this
so it does not read as removable.

Root uv.lock is reformatted from lock revision 1 to 3 because uv rewrites
in its current format whenever it writes. The other 32 locks in the repo
are already revision 3 and CI's setup-uv is unpinned, so this aligns root
rather than drifting it. Only 3 versions actually change.

Verified: local-ml sync resolves tokenizers 0.22.2 under transformers
5.12.1; LocalSTEmbeddings and LocalSTCrossEncoder both initialize and run
(the #2055 import path). Lint passes.
2026-07-17 11:13:08 -04:00
Derek Bouius eca0fd5a29 test(openrouter): set cached-token fields in mock to stop intermittent MagicMock crash (#2776)
test_null_content_recovers_on_retry failed intermittently on the test-api
shard with:

  hindsight_api/metrics.py:591: TypeError: '>' not supported between
  instances of 'MagicMock' and 'int'   (if cached_input_tokens > 0)

The mock in _make_chat_response set completion_tokens_details but not the
cached-token fields, so the cached-token extraction
(openai_compatible_llm.py:948 `response_usage.cached_tokens`, and the
prompt_tokens_details path) read an auto-MagicMock and passed it to the
metrics recorder. It only surfaced when the metrics path actually ran —
which depends on telemetry state that leaks across pytest-xdist workers —
so it presented as an intermittent, co-scheduling-dependent failure rather
than a deterministic one.

Set usage.cached_tokens = 0 and usage.prompt_tokens_details = None so both
extraction paths yield int 0. Verified: both tests pass and both paths
return int 0 (no MagicMock reaches the `> 0` comparison).
2026-07-17 10:56:22 -04:00
Jordan-Jarvis 44398633bb fix(api): decode memory observation scopes (#2735) 2026-07-17 10:48:18 -04:00
Nick Old 52b893b93b fix(embed): defer provider credential validation (#2746) 2026-07-17 10:33:06 -04:00
Jordan-Jarvis 52c216c1fb fix(reflect): preserve bank attribution in provider calls (#2764)
Bind Reflect to the existing per-bank ContextVar so its tool loop and final synthesis preserve provider cost attribution. Replace two direct ContextVar implementation tests with one integrated Reflect binding/reset regression.
2026-07-17 10:19:12 -04:00
Ehsan d9bc612a3c fix(openai): record cached and reasoning tokens on the LLM metrics counters (#2758)
The OpenAI-compatible provider extracts cached_tokens and thoughts_tokens on
both call paths and hands them to TokenUsage, but never passes them to
metrics.record_llm_call, which accepts and buckets both. Two separate effects:

- Reasoning tokens reach no counter at all. #2378 made output_tokens
  visible-only by subtracting thoughts_tokens directly above the
  record_llm_call, so the reasoning half of the billed output was removed
  from the metrics path rather than moved onto llm_tokens_thoughts. Before
  #2378 those tokens were still counted inside output_tokens.
- cached_input_tokens has read 0 for every OpenAI-compatible provider since
  the counter was added; only gemini_llm passes it.

Pass both kwargs at the two call sites that parse a usage object. The
fallback path (no usage) and the Ollama native path (no reasoning or cached
fields) are unchanged.

Invariant: recorded output_tokens + recorded thoughts_tokens equals the
provider's completion_tokens, so every billed token lands on exactly one
counter. The new tests assert on the collector itself; the existing ones
patch it without asserting, which is why this went unnoticed.
2026-07-17 09:58:54 -04:00
Ehsan 1fe43ec3fb fix(reflect): pair each expanded memory_id with its own memory (#2759)
tool_expand zipped memory_ids against valid_uuids, which only collects the
ids that parsed as UUIDs. One invalid id shifts every later pair by one, so
a memory comes back stamped with a different memory's id, and zip truncates
the tail so the last requested id gets no entry at all.

Key each id to its own UUID and iterate memory_ids directly, so an invalid
id can only affect its own entry.
2026-07-17 09:58:46 -04:00
Derek Bouius ca87e29891 test(fact-extraction): stop judging phrasing/attribution the system already captures (#2769)
Two hs_llm_core quality tests failed frequently on the core-LLM job, not
because the judge flaked (it is already temp-0 primary + majority-vote
confirmations) but because they judged model output that is genuinely
variable and already checked deterministically elsewhere.

test_date_field_calculation_yesterday: the resolved date lives in the
structured `occurred_start` field, which the test already asserts is
Nov 12/13. The judge additionally required the absolute date to appear in
the free-text fact prose ("...state the absolute date in the fact text"),
so a correct extraction that wrote "Yesterday" in prose but Nov 12 in
occurred_start still failed. That tested phrasing, not capability. Make the
occurred_start assertion mandatory (require a dated fact — calculating the
date is the point of the test) and drop the date clause from the judged
criteria; the judge now only checks the fuzzy activity-content claim.

test_cognitive_epistemic_dimension: the judge penalised entity/speaker
attribution ("Involving: She/He") that is not what this test is about — it
asserts cognitive/epistemic *states* survive extraction. Scope the criteria
to that dimension and instruct the judge to ignore attribution and wording,
so a state counts as preserved even if attributed to the wrong person.

Both still catch real regressions (missing/incorrect dates, dropped
cognitive states); they just no longer flake on aspects the system either
captures structurally or does not claim to get right. Verified locally: both
pass (extraction gpt-4o-mini, judge gpt-4.1-mini).
2026-07-17 08:17:31 -04:00
Derek Bouius d2b14e51ee fix(test): seed torch._inductor.test_operators to fix test-api shard failures (#2761)
* fix(test): seed native embedding/reranker stack to fix test-api shard failures

test-api's reranker-bearing shard (consistently 2/3) has failed on every
recent run — this repo's dependency PRs and Dependabot's alike — with a
misleading "sentence-transformers is required for LocalSTEmbeddings"
ImportError. sentence-transformers IS installed; the message masks the real
cause. The full worker traceback shows native extensions double-initializing:

  torch._inductor.test_operators (module body runs twice):
    RuntimeError: Only a single TORCH_LIBRARY can be used to register the
    namespace _inductor_test
  safetensors._safetensors_rust (PyO3):
    ImportError: PyO3 modules ... may only be initialized once per
    interpreter process

transformers' lazy loader imports these while resolving classes like
AutoModelForSequenceClassification / GenerationMixin (used by the
cross-encoder), and when they are first imported from inside a fixture's
event loop / sentence-transformers' thread pools — or re-executed by the
loader's retry path — the second init aborts. transformers wraps the error
and re-raises it as the sentence-transformers ImportError, so the symptom
points at the wrong dependency.

This is the same class of bug the adjacent `import torch` seed already guards
against (torch/overrides.py double-init). Extend that seed to the rest of the
native stack: torch._inductor.test_operators, transformers, and
sentence_transformers (which pulls safetensors + tokenizers). Importing them
once at conftest collection time — single-threaded, before any concurrency —
puts every submodule in sys.modules so later imports are cache hits and no
body re-executes. Verified locally.

Version-independent (reproduced at transformers 5.3.0 and 5.12.1, torch 2.10
and 2.12), which is why it blocked every uv.lock-changing PR regardless of
what they bumped.

* fix(test): auto-assign embedded postgres port in backfill migration test

test_backfill_populates_null_observation_search_vector pinned its embedded
postgres to a hardcoded port 5568. Under pytest-xdist that collides with a
concurrent or left-over instance:

  FATAL: could not create any TCP/IP sockets
  could not bind IPv4 address "127.0.0.1": Address already in use

which the pg0 retry loop reports as "Failed to start embedded PostgreSQL
after 5 attempts". This was the lone remaining error on test-api shard 2/3
after the native-import fix (66 of 67 errors were the masked double-init;
this was the 67th).

EmbeddedPostgres already supports port=None to auto-assign a free port, and
the fixture uses the URL from ensure_running(), so nothing needs the fixed
port. Switch to auto-assign.
2026-07-17 07:36:27 -04:00
Ehsan 9676fc1699 fix(entity-resolver): keep every co-occurrence pair when canonicalising order (#2750)
The pair canonicalisation in _link_units_to_entities_batch_impl swapped
entity_id_1 and entity_id_2 in place, but entity_id_1 is the outer loop's
iterate:

    for i, entity_id_1 in enumerate(entity_list):
        for entity_id_2 in entity_list[i + 1:]:
            if entity_id_1 > entity_id_2:
                entity_id_1, entity_id_2 = entity_id_2, entity_id_1

Once a swap happens, entity_id_1 stays swapped for the rest of that inner
loop, so every later pair in the same outer iteration is built from the
wrong first element. Those pairs collide with ones already emitted, so the
effect is silently missing edges rather than wrong ones.

entity_list comes from a set, so the ordering (and the bug) varies per run.

Move the canonicalisation into a _canonical_cooccurrence_pairs() helper that
orders each pair into fresh locals, leaving the iterate untouched, and cover
it with order-pinned unit tests that need no database.
2026-07-16 16:21:45 -04:00
Ehsan 73a5b576c9 fix(reflect): keep horizontal rules inside fenced code blocks (#2755)
parse_markdown() blanked every line matching the horizontal-rule pattern
before any fence tracking ran, so a --- / *** / ___ line inside a fenced
code block was replaced by an empty line and the content was lost.

_strip_separators() was fence-unaware and ran first; _split_blocks() is
the pass that tracks fences. Fold the rule-skip into _split_blocks, which
already carries the in_fence state, so there is one fence state machine
instead of two. A rule between sections still counts as blank and still
never becomes a paragraph.

Fixes #2752
2026-07-16 16:13:47 -04:00
Ben 685e50b9af blog: One Bank or Many? A Field Guide to Structuring Agent Memory (#2747)
* blog: One Bank or Many? structuring agent memory

A field guide to bank strategy in Hindsight: a bank is a recall
boundary, when to use separate banks vs tags within one bank, the
dynamicBankId/granularity config, anti-patterns, and a decision
checklist. All claims grounded in the source.
2026-07-16 14:37:55 -04:00
Derek Bouius c27fafb298 chore(deps): npm transitive pins (1 critical + mediums) and pydantic-ai-slim (#2751)
* chore(deps): bump pydantic-ai-slim to 1.107.1 (security)

  pydantic-ai-slim 1.99.0 -> 1.107.1  GHSA-cg7w-rg45-pc59

Closes the SSRF-blocklist-bypass alert (IPv4-compatible / SIIT/IVI /
NAT64 IPv6 addresses; incomplete fix of CVE-2026-46678; patched 1.102.0).

Transitive via the hindsight-pydantic-ai integration. Held to the 1.x
line rather than the 2.x that an unconstrained upgrade resolves to
(2.11.0) -- pydantic-ai 2.x is a major with its own migration surface,
out of scope for a medium security bump. 1.107.1 clears the advisory
within the same major.

Verified: uv run pytest tests -> 37 passed.

* chore(deps): pin websocket-driver/http-proxy-middleware/js-yaml/uuid via overrides (security)

Closes one critical and three medium Dependabot alerts on transitive npm
deps in the root lock, using the repo's existing `overrides` mechanism.

  websocket-driver      0.7.4  -> 0.7.5    GHSA-xv26-6w52-cph6 (CRITICAL:
                                           message corruption via protocol
                                           length headers) + GHSA-mp7j-qc5w-4988
  http-proxy-middleware 2.0.9  -> 2.0.10   GHSA-64mm-vxmg-q3vj (Host-header
                                           routing bypass); capped <3 to stay
                                           on the 2.x major webpack-dev-server
                                           expects
  js-yaml (3.x)         3.14.2 -> 3.15.0   GHSA-h67p-54hq-rp68 (merge-key DoS);
                                           scoped to @istanbuljs/load-nyc-config
                                           and gray-matter so the 4.x copies are
                                           untouched
  uuid (sockjs)         8.3.2  -> 11.1.1   GHSA-w5hq-g745-h8pq (buf bounds);
                                           scoped to sockjs so the top-level
                                           uuid 14.x is untouched

All four are dev/build tooling (webpack-dev-server, sockjs, istanbuljs
coverage, gray-matter frontmatter). Applied by adding overrides then
`npm update <pkg>` per target -- `npm install` alone registers an override
but will not upgrade an already-locked transitive to satisfy it. Verified
`npm ci` installs the lock cleanly and resolves the patched versions.

Two root-lock npm alerts are intentionally left for separate PRs:
- postcss <8.5.10 (GHSA-qx2v-qp2m-jg93): only reachable via [email protected],
  which pins postcss==8.4.31 exactly. npm registers an override but will
  not rewrite next's nested copy, and forcing it risks next's build. The
  real fix is a next bump. Low real risk -- the app compiles first-party
  (Tailwind) CSS, not attacker-controlled input.
- @hey-api/openapi-ts <0.97.3 (GHSA-hhx9-57xq-r5rw): the SDK generator;
  the patched line is a breaking change that needs client regeneration.

* chore(deps): bump langgraph-checkpoint and langgraph-sdk (security)

  langgraph-checkpoint 4.1.0  -> 4.1.1   GHSA-fjqc-hq36-qh5p
  langgraph-sdk        0.3.14 -> 0.3.15  GHSA-w39p-vh2g-g8g5

Both transitive medium alerts in the hindsight-langgraph lock. (The
langsmith bump that originally shared this file landed separately in
#2743; only checkpoint/sdk remain.)

Verified: uv run pytest tests -> 60 passed, 6 skipped.
2026-07-16 12:11:17 -04:00
Ben 37fa0adf93 docs: fix conversation-scoped bank claim in Omnigent post (#2748)
A conversation-scoped bank is not wiped when the conversation ends.
The bank persists; a new conversation simply resolves to a new bank,
so memory does not carry across conversations. Corrects an inaccurate
'wiped' claim in the bank-scoping section.
2026-07-16 10:28:46 -04:00
Derek Bouius b95055ba28 chore(deps): migrate pipecat integration to pipecat-ai 1.x (security) (#2380)
Bumps pipecat-ai from 0.0.x to >=1.4.0,<2.0, clearing four high-severity
Dependabot advisories for the file-read CVEs in the older 0.0.x/1.0.x line
(telephony /ws + runner /files path traversal; alerts #1006, #1005, #560, #559).

pipecat 1.x replaced the per-provider OpenAILLMContext with the universal
LLMContext and removed the pipecat.processors.aggregators.openai_llm_context
module. The integration already imported the modern LLMContextFrame, so the
runtime change is small:

- memory.py: drop the now-impossible legacy OpenAILLMContextFrame import branch
  and match on LLMContextFrame directly. LLMContext.messages is still a live
  list of OpenAI-format dicts, so the in-place injection logic is unchanged.
- tests: build frames from LLMContextFrame; add TestRealLLMContext that exercises
  a real pipecat LLMContext + LLMContextFrame to pin the live-list mutation
  contract the integration depends on.
- examples: migrate to LLMContext + LLMContextAggregatorPair and the LLMRunFrame
  kickoff (create_context_aggregator / get_context_frame were removed in 1.x).
- pyproject: pipecat 1.x requires Python >=3.11, so bump requires-python and
  drop the 3.10 classifier (CI already runs 3.11).

Tests: 19 passed, 1 skipped (live).
2026-07-16 09:48:37 -04:00
Derek Bouius 4b78761d20 chore(deps): bump langsmith and ws (security) (#2743)
langsmith  0.8.3  -> 0.10.5  GHSA-f4xh-w4cj-qxq8 (arbitrary server-side
                               file read in TracingMiddleware; patched 0.8.18)
  ws         8.18.0 -> 8.21.0  GHSA-96hv-2xvq-fx4p (memory-exhaustion DoS)

Both are transitive. langsmith pulls in distro/sniffio/websockets as new
langsmith 0.10.x deps. hindsight-api-slim already carries a langsmith
>=0.8.18 floor; this covers the langgraph lock, which did not.

ws could not be bumped directly: miniflare pins it exactly (ws==8.18.0),
so the fix is via wrangler. wrangler >=4.108.0 requires peer
@cloudflare/workers-types ^5, a types major we don't want in a security
fix, so pin 4.107.1 -- the newest wrangler still on workers-types v4
(peer ^4.20260702.1) and the earliest line carrying patched ws 8.21.0.
That moves workers-types 4.20260617.1 -> 4.20260702.1 within v4. wrangler
is a devDependency, so this ws is dev-only (miniflare's local dev server);
the deployed Worker's only runtime dep is @cloudflare/workers-oauth-provider.

The langgraph lock also picks up hindsight-langgraph 0.2.0 -> 0.3.0.
That is pre-existing drift, not part of this change: release(langgraph)
v0.3.0 (2c5362942) bumped pyproject without re-locking. uv corrects it here.

json-repair (GHSA-xf7x-x43h-rpqh) is deliberately not addressed: it is
blocked upstream. Every crewai release, including the latest 1.15.2, pins
json-repair~=0.25.2 (>=0.25.2,<0.26.0), and the advisory is not patched
until 0.60.1. No crewai version permits a fixed json-repair.

Verified: cloudflare-oauth-proxy `npm ci` + `npm run typecheck` (CI's gate)
pass, npm audit reports 0 vulnerabilities, vitest 50 passed; langgraph
pytest 60 passed, 6 skipped. Lint passes with LINT_ALL_INTEGRATIONS=1.
2026-07-16 09:47:41 -04:00
BenandClaude Opus 4.8 1549987015 docs: Add Omnigent integration page (#2710)
* docs: add Omnigent integration page

Adds the Omnigent integration to the docs site:
- docs-integrations/omnigent.md — full integration guide (install, YAML
  config, how runner-local dispatch works, bank scoping, config reference,
  self-hosted, Remy example, harness table, further reading)
- src/data/integrations.json — registry entry (category: framework, official)
- static/img/icons/omnigent.png — placeholder icon (to be updated)

Tool names use the correct Omnigent source names: memory_recall/retain/reflect.

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

* docs: fix broken links, real Omnigent logo, regen skill

- Remove changelog link (Omnigent has no released Hindsight package/changelog)
- Drop the not-yet-merged blog self-link; add Omnigent GitHub link instead
- Replace placeholder icon with the real Omnigent logo (from omnigent-ai/omnigent)
- Regenerate skills/hindsight-docs integration reference for omnigent

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

* docs(omnigent): correct tool names to hindsight_* + fix harness table

- Revert memory_* -> hindsight_recall/retain/reflect: released omnigent v0.5.1
  (and main, and the Remy example) use hindsight_* names. The memory_* rename
  is on an unmerged branch (integration/hindsight-memory-tool), not released.
- Fix the harness table: Codex and OpenCode have official Hindsight integrations,
  Pi has a community one (epimetheus); reframe around 'one central setup' rather
  than implying those tools have no native support.
- Regenerate skills/hindsight-docs reference.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-15 15:38:15 -04:00
Ben b0e5d103d8 Blog: Give Every Agent You Run in Omnigent a Persistent Memory (#2709)
* Blog: Omnigent as the universal Hindsight memory bridge

Tutorial-style post on adding persistent memory to Omnigent. Key angle:
Omnigent intercepts hindsight_recall/retain/reflect at the runner level, so
every wrapped harness (Claude Code, Codex, Cursor, Hermes, Pi) gets memory
through one setup, even those with no native Hindsight support. Covers
install, YAML spec, bank scoping, the Remy example, and cloud/self-hosted.
Grounded in omnigent-ai/omnigent source. Bridge-diagram cover.
2026-07-15 15:15:07 -04:00
Minghao Xiao 5ab6bdc9b6 fix(openclaw): gate append retention on stored text (#2511)
Fixes #2505: the OpenClaw append-capability probe only checked API version, ignoring features.store_document_text, so every session-scoped retain 400'd (silent memory loss) on text-disabled deployments. Now gates update_mode=append on BOTH version >= 0.5.0 AND features.store_document_text=true, falling back to per-turn document IDs otherwise. Verified locally: 281/281 openclaw tests pass on the PR head.
2026-07-15 11:18:46 -04:00
handnewb ed1083803b fix: coerce non-string metadata values to strings in MemoryFact.parse_metadata (#2623)
Fixes the consolidation blocker from non-string metadata values (e.g. integer `original_id` from observation bookmarks) by coercing all metadata values to str in `MemoryFact.parse_metadata`. Verified locally: 4/4 regression tests pass (integer coercion, JSONB-string-with-int, string passthrough, None).
2026-07-15 11:08:05 -04:00
Ben ec3b415c42 feat(retain): optional fail-on-extraction-errors flag (#2721)
Add opt-in HINDSIGHT_API_FAIL_ON_EXTRACTION_ERRORS (default False, preserves behavior). When enabled and a retain accumulated extraction errors (extraction_errors_count > 0), the operation is marked failed instead of silently completed. Self-contained: config flag + _mark_operation_completed decision + docs + .env.example. Deferred follow-ups (status API field, completed_with_errors status, webhook field, metric) noted in the PR.

Own change verified (config + status tests pass, verify-generated-files green, ruff/tsc clean). Remaining CI reds are unrelated: Core LLM + pg0 test-api flakes, a transient ts-client-oracle, and test-embed-windows (the #2676 regression already fixed on main by #2723; #2721 does not touch the embed daemon).

Fixes #2700
2026-07-15 10:58:00 -04:00
Ben 3a188971b9 fix(embed): use --target sibling binary unconditionally (#2723)
Resolve the #2676-vs-#1240 conflict that broke test-embed-windows on main: scope #2676's 'missing sentence-transformers -> uvx' fallback to the sysconfig-scripts path only. A --target-bundled sibling binary is deliberate and is used unconditionally (preserving #1240). Adds a regression test; fixes a test fixture that conflated the two binary-resolution paths.

Greens main.
2026-07-15 10:56:57 -04:00
Nick Old b3e32ce5b6 fix reranker heap release after local scoring (#2530) 2026-07-15 10:49:21 -04:00
Ben d0bbfa1015 fix(gemini): grammar-enforce batch structured output regardless of strict flag (#2719)
The Gemini batch request builder only set the native response_schema
(responseJsonSchema) when json_schema.strict was truthy, but
HINDSIGHT_API_LLM_STRICT_SCHEMA defaults to False. The interactive Gemini
path always grammar-enforces via response_schema regardless of strict
(strict is an OpenAI concept, meaningless to Gemini). At default config
batch requests therefore got only responseMimeType + a textual schema hint
and intermittently emitted malformed JSON, dropping every fact in the chunk.

Set responseJsonSchema whenever a schema is present so batch mirrors
interactive. Update the batch translation unit test accordingly.

Fixes #2699
2026-07-15 10:33:39 -04:00
Ben 043a68fa44 fix(retain): recover batch extraction JSON via parse_llm_json (#2720) 2026-07-15 10:32:21 -04:00
Ben 7542035e44 fix(test): update Oracle session-schema tests for #2708 reset behavior (#2722)
#2708 changed OracleBackend._set_session_schema to always reset CURRENT_SCHEMA
to the connection's default (SESSION_USER) schema — including for the public
schema — because Oracle pooled sessions retain CURRENT_SCHEMA across checkouts.
That intentional change left two #2613 unit tests asserting the old
'public = noop, no cursor' contract, and their mock cursor lacked the fetchone()
now used to look up SESSION_USER, so both failed on main.

Update the tests to the new contract: public now resets to the default schema
via ALTER SESSION, and the mock cursor provides fetchone(). The synchronous
cursor.close()-not-awaited assertion is preserved.
2026-07-15 10:30:05 -04:00
Ben bee6f5d114 fix(control-plane): show bank name (fallback bank_id) in bank selector (#2693)
The bank selector rendered bank_id for both the dropdown items and the
selected-bank trigger, ignoring the bank's friendly name even though it's
already available on BankInfo (name). Admins who rename banks via
PATCH /v1/default/banks/{bank_id} saw only the immutable bank_id in the UI.

Display name || bank_id in the dropdown items and look up the selected
bank's name for the trigger, falling back to bank_id (then the 'select'
placeholder) so there's no regression before the bank list loads or when a
bank has no name. bank_id remains the key/value/clipboard identifier.

Fixes #2686
2026-07-15 10:17:32 -04:00
Liam Zhang e20b1815fc [verified] docs(embed): expose local CPU workarounds (#2707) 2026-07-15 10:03:50 -04:00
Ben 395823f7b6 release(claude-code): v0.7.5 2026-07-14 14:19:40 -04:00
Nick Old a910fd8a0b fix(claude-code): retain session deltas (#2648)
* fix(claude-code): retain session deltas

* fix(claude-code): commit retain checkpoint after success
2026-07-14 14:18:26 -04:00
Parafee41 64ee029a18 Avoid slim embedded daemon startup without local ML deps (#2676)
* fix(embed): avoid slim daemon without local ML deps

* test(embed): pin slim binary preconditions
2026-07-14 14:18:22 -04:00
Elan Hasson 25df91ca53 fix(claude-code): surface the CLI's real error text on is_error results (#2703)
ClaudeCodeLLM's streaming loops ignored ResultMessage entirely. When the CLI reports quota exhaustion with is_error=true and subtype="success", the SDK's fallback produced the misleading 'error result: success'. Add _result_error_detail() that prefers message.result over subtype, wired into both loops. 4/4 regression tests pass.

Fixes #2702
2026-07-14 10:10:02 -04:00
Parafee41 8987fb8267 fix(codex): share OAuth refresh per auth-file path (#2706)
Multiple CodexLLM instances (default/retain/reflect/consolidation configs) each created their own CodexAuthManager with an instance-local lock, so refresh was only single-flight within one manager. Concurrent refreshes from sibling managers hit refresh_token_reused. Add a path-scoped in-process lock and fcntl advisory file lock so all managers for the same CODEX_HOME coordinate as one refresh domain; pre-read auth.json under the lock to adopt credentials rotated by a sibling before making a network call.

27 Codex OAuth tests pass. CI green.

Fixes #2704
2026-07-14 10:08:36 -04:00
Voscko 86ff344c93 fix(worker): bound terminal operation history (#2708)
Add configurable TTL (default 30 days, 0=keep-forever) for terminal async_operations rows. Expired completed/failed/cancelled rows are pruned in bounded batches (1000/cycle) by a background task that never touches pending/processing work. Batch children are protected until their parent is pruned; cancelled-child cleanup atomically cancels a pending parent first. PG uses FOR UPDATE SKIP LOCKED; both PG and Oracle re-check eligibility under the row lock before deleting. Includes indexes, docs, and regenerated SDKs.

184 retention/worker/operation-status tests pass locally. All CI green.

Fixes #2705
2026-07-14 10:04:26 -04:00
DK09876andClaude Opus 4.8 b6c7b2a2e9 feat(devin-desktop): two-tier bank scoping + visible memory use (v0.2.0) (#2692)
* feat(devin-desktop): two-tier bank scoping + visible memory use (v0.2.0)

Reworks the Devin Desktop integration from a single hardcoded `devin-desktop`
bank (all projects share one memory pool) to per-project isolation plus a
shared cross-project bank, and makes Hindsight usage visible in chat.

Scoping (multi-bank mode):
- Connect to the multi-bank `/mcp/` endpoint (was `/mcp/<bank>/`); the model
  routes `bank_id` per call, guided by the committed rule.
- Global bank `devin-desktop` (user prefs/style) named in global_rules.md;
  per-project bank `devin-desktop-<slug>` derived from the git remote (stable
  across machines/teammates) named in the committed .devin/rules/hindsight.md.
- `X-Bank-Id: <global>` header as the fallback bank when the model omits it.
- Verified against live Cloud: bank_id routing + full isolation (no cross-bank
  leak) + read-after-write via sync_retain.

Visibility (no sound, per product decision):
- Rule now tells the agent to briefly acknowledge memory use in chat
  (reverses the prior "do not mention" line) and to use `reflect`/`sync_retain`.

Audit fixes:
- Write both documented MCP config locations (`~/.codeium/windsurf/` and
  `~/.codeium/`) since Devin's own docs disagree on the path.
- Explicit "press Refresh in the MCP panel" step (config doesn't hot-reload).

New modules: project.py (git-derivation), global_rules.py (global_rules.md
managed block). Backward-compatible: legacy `bankId` config maps to the global
bank. 55 tests pass; ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* feat(devin-desktop): also wire the Devin Local agent (not just Cascade)

Devin Desktop ships two agents with separate config, and the prior version only
wired Cascade — so a user on Devin Local (the successor agent) got no memory.
`init` now configures both:

Cascade (unchanged): ~/.codeium/windsurf/mcp_config.json (serverUrl),
.devin/rules/hindsight.md, ~/.codeium/windsurf/memories/global_rules.md.

Devin Local (new):
- ~/.config/devin/config.json — mcpServers.hindsight with `url` + `transport:"http"`
  + `headers` (Devin Local's schema, not Cascade's `serverUrl`); preserves other
  keys (e.g. version).
- permissions.allow += "mcp__hindsight__*" — Devin Local prompts before every MCP
  tool by default; this makes recall/retain run automatically.
- AGENTS.md always-on rules (Devin Local doesn't read .devin/rules/): repo-root
  AGENTS.md (per-project) + ~/.config/devin/AGENTS.md (global), each a fenced
  managed block that preserves user content.

New modules: devin_local.py, managed_block.py (shared block writer, also used by
global_rules.py). Same multi-bank + routing-rule design across both agents.
status/uninstall cover both. README + docstrings updated. 74 tests pass; ruff
clean (ruff 0.14.9 + root config).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* docs(devin-desktop): tell users to click Connect (Devin Local) after init

Devin Local registers the MCP server from config.json but requires an explicit
Connect click in the Devin MCP Marketplace (verified in-app). init output and
README now spell out the per-agent activation step: Cascade = Refresh, Devin
Local = Connect.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* feat(devin-desktop): deterministic auto-recall hook + Windows paths

Two things for 0.2.0:

1. Windows paths: Devin Local config/AGENTS.md now resolve to %APPDATA%\devin on
   Windows (was ~/.config/devin unconditionally, which is wrong there). Cascade's
   ~/.codeium/windsurf is already cross-platform.

2. Deterministic auto-recall (Devin Local only): init adds a SessionStart hook to
   config.json that recalls project + global memory and returns it as
   `additionalContext`, which Devin injects into the agent's context before the
   model acts — so memory loads even if the model forgets to call recall. The
   hook (hindsight_devin_desktop.hook) reads the connection from config.json and
   derives the project bank from DEVIN_PROJECT_DIR; it's dependency-free (stdlib
   urllib MCP call), times out fast, and fails silently so it never breaks a
   session. Opt out with `init --no-hooks`. Cascade gets no hook (its hooks can't
   inject context). Auto-retain is intentionally not added (SessionEnd can't see
   the transcript); retain stays model-driven via the MCP tool.

Verified live against Cloud: the hook recalls a stored fact and emits correct
additionalContext JSON. 89 tests pass; ruff clean. README documents both.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* feat(devin-desktop): retain-nudge hook, no-silent recall, Cascade visibility banner

Round out the hooks/visibility work for 0.2.0:

- Retain-nudge (Devin Local, default on): a `Stop` hook forces one retain pass
  before the agent stops (loop-guarded via stop_hook_active) — deterministic
  *trigger*; the model decides what's durable and calls retain. Devin's hooks
  can't hand a script the transcript, so this is the closest to deterministic
  retain. Opt out with --no-retain-hook; --no-hooks disables both hooks.

- No silent failures (recall hook): the SessionStart hook now ALWAYS reports
  status via additionalContext — loaded N / empty / unavailable(reason) — and
  tells the model to surface it. Never exits non-zero (never breaks a session).

- Cascade visibility banner: init adds a `post_mcp_tool_use` hook to
  ~/.codeium/windsurf/hooks.json with show_output:true that prints
  "🧠 Hindsight: <tool> used" (filtered in-script to the hindsight server, since
  Cascade hooks have no matcher). Makes Cascade's recall/retain visibly obvious.

New module cascade_hooks.py; hook.py gains retain-nudge + banner subcommands.
README documents both hooks, the honest retain limitation, and the banner.
102 tests pass; ruff clean. Recall + retain-nudge output verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* fix(devin-desktop): stop the retain-nudge polluting memory with meta-facts

Real in-app testing showed the Stop retain-nudge caused the model to (a) retain
facts ABOUT the memory system/instructions as 'user preferences', and (b)
re-retain things already saved this session. Tighten both the nudge and the
always-on rule: retain ONLY real facts about the code/project/user's actual
preferences, NEVER facts about Hindsight/memory/hooks/these instructions, and
don't re-retain what's already stored.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* fix(devin-desktop): rule tweak to stop redundant retains

In-app testing showed the model firing sync_retain per-fact (and re-saving),
producing duplicate memories. Reframe the rule: retain (async) is the default;
retain each distinct fact EXACTLY ONCE in a single call (batch same-subject
facts); sync_retain only for same-task read-after-write.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* feat(devin-desktop): hook proof-of-life log (~/.hindsight/devin-hook.log)

Devin Local hooks are silent (no output panel), so it's hard to tell whether a
hook actually fired vs the model just following the always-on rule. Each hook
invocation now appends one line (recall loaded/empty/error, retain-nudge
blocked/skipped, banner shown/skipped) with the resolved banks — proof-of-life
so users (and we) can confirm the hooks run.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* feat(devin-desktop): --no-global-bank opt-out (local-only memory)

Add a local-only mode so users can opt out of the shared cross-project bank:
everything (project facts + the user's preferences) goes to the single project
bank, the global rule files are removed instead of written, and the recall +
retain-nudge hooks run with --local-only (recall only the project bank, nudge
routes everything there). The rule becomes a single-bank variant. Cascade
banner + MCP config unchanged. For people who don't want a shared profile
(e.g. work vs personal machines).

108 tests pass; ruff clean. Verified end-to-end: no global files written, hooks
carry --local-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* fix(devin-desktop): don't let the test suite write the hook log to $HOME

The proof-of-life hook log wrote ~/.hindsight/devin-hook.log unconditionally, so
running the tests (which call the hook functions) polluted the real user log.
Make the path env-overridable (HINDSIGHT_HOOK_LOG, 'off' disables) and add a
conftest that sets it off during tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* feat(devin-desktop): recall hook reports the session-start preload

The SessionStart hook's additionalContext now tells the model to OPEN its reply
by announcing that memory was preloaded (e.g. '🧠 Hindsight preloaded N memories
for this session'), and that it doesn't need to re-call recall for the baseline
— making the deterministic preload visible to the user and cutting redundant
recall calls. Empty/error variants also lead with a user-facing status line.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* docs(devin-desktop): add 'verify it's working', which-agent, and Windows notes

Help new users get started with both agents: a 'Verify it's working' section
(the preload status line / hook log / Cascade banner / status command), a note
on the agent selector, and the Windows config path.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-13 15:52:21 -07:00
BenandClaude Opus 4.8 9efce6a470 Blog: Inside retain() — What Happens When Your Agent Remembers (#2689)
* Blog: inside retain() — what happens when your agent remembers

A feature explainer walking the retain() write path end to end through one
sentence: fact extraction (meaning, not words), entity recognition + resolution,
the knowledge graph (entity/time/meaning/causal), dual temporal grounding, and
async consolidation into evidence-grounded observations. Grounded in the retain
and observations developer docs. Pipeline-diagram cover.

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

* Blog: address review — drop async-only timing claims, note original text is stored

- Remove 'returns almost immediately' / inline-extraction language that only
  holds for one retain mode; frame consolidation as the always-background step
- Add that retain also stores the original text (chunked if long), available
  alongside the extracted memory
- Cover line updated to 'the raw text is kept, and memory is built on top'

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

* Blog: fact-check fixes from full code+docs audit

- Remove 'nickname resolution' (Bob/Robert Chen): code has no nickname/alias
  logic; resolution is fuzzy name match + co-occurrence + temporal proximity
- Temporal: second axis is the mention time, not the DB insert moment; recency
  ranks off event/mention time, not ingestion
- Soften 'source is never lost' -> 'stays available' (original-text storage is
  default-on but operator-configurable)

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

* Blog: editorial cover (cream + serif, teal retain())

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-13 15:25:12 -04:00
Ben 6cc7484b78 fix(migrations): install public maintenance routines on non-public schema runs (#2690)
Install public.banks_needing_consolidation() / public.schemas_with_expired_rows() on every PG run (advisory-lock-guarded) so single-tenant deployments migrated into a non-public schema get the routines the maintenance loop needs. Prior migrations gated on target_schema being falsy/public, leaving non-public deploys logging 'function public.… does not exist' forever.

All real CI tests pass (test-api 1/3+3/3, and 1237/1237 in shard 2/3; test-upgrade, both Oracle suites, verify-generated-files green). Sole failing check is a persistent pg0 'Address already in use' runner-infra error in an unrelated backfill test — not a test failure and not from this diff; recurred on all 3 reruns.

Fixes #2638
2026-07-13 13:29:13 -04:00
DK09876 920afcce20 release(devin-desktop): v0.1.0 2026-07-13 10:00:35 -07:00
Evo 6d82c8a554 fix(consolidation): request JSON dedup decisions (#2663)
The dedup prompt literally told the model to 'respond action="merge"', so weaker models emitted key=value instead of JSON and json.loads crashed every dedup-eligible consolidation into an infinite retry loop. Rewrite the prompt to demand a JSON object (braces escaped for .format()), and add a defensive parser that accepts str/dict/model and defaults to action=keep on invalid output. Fork CI skips pytest (no secrets); test_consolidation_dedup.py verified locally (32/32), ruff+ty clean.

Fixes #2658
2026-07-13 11:03:49 -04:00
Sanderhoff-alt 4b52b10e2e fix(recall): preserve combined graph activation scores (#2679)
Link expansion ranks candidates by an additive entity, semantic,
and causal score, but returned the raw score from one signal as
activation. Cross-fact-type graph merging then re-ranked candidates
using that raw value.

Store the final additive score as activation and add a regression test
for cross-fact-type ordering.
2026-07-13 10:35:20 -04:00
Vilius PuidokasandVilius Puidokas ac06df1ade fix(control-plane): route consolidation-poll tick through a ref so it sees current tag/scope filters (#2680)
Co-authored-by: Vilius Puidokas <[email protected]>
2026-07-13 10:31:04 -04:00
Evoandr266-tech 5f1a867650 fix(search): avoid year-0 crashes in Chinese rolling-window temporal extraction (#2636)
* fix(search): guard Chinese rolling year underflow

* fix(search): complete Chinese year underflow guard

---------

Co-authored-by: r266-tech <[email protected]>
2026-07-13 10:14:28 -04:00
Ben d284119246 fix(control-plane): forward document search q to dataplane (#2687)
The /api/documents proxy route dropped the q search param, so document search-by-ID in the control plane did nothing (all browsers, not just Safari). Forward q to the dataplane's substring-on-ID filter. Adds a vitest route test.

Fixes #2678
2026-07-13 10:11:56 -04:00
Sanderhoff-alt 84b9aa56ce fix(engine): clarify causal link compatibility (#2685)
Clarify that retain creates caused_by only. Storage and recall keep reading
historical causal link types, and transfer import alone restores them.

Correct stale code comments and tests, and preserve legacy edge types and
endpoints during transfer without widening the retain write contract.
2026-07-13 10:07:26 -04:00
Parafee41 f58ecee0b9 fix(retain): preserve append document metadata (#2684) 2026-07-13 10:01:17 -04:00
Sanderhoff-alt b9d16fe86f fix(recall): scope entity fanout cap by fact type (#2681)
Apply each entity fanout cap only after filtering candidates by fact type.
This prevents high-volume fact types from excluding valid target candidates.

Cover the PostgreSQL and Oracle CTE builders with a regression test.
2026-07-13 09:54:22 -04:00
Parafee41 2ccde7a5cd accept top-level fact arrays in retain parsing (#2556) 2026-07-13 09:32:00 -04:00
Parafee41 d2ca26afaf Include cookbook and integration docs in docs skill (#2649)
Extends generate-docs-skill.sh to walk hindsight-docs/src/pages/cookbook/ and docs-integrations/, so the docs skill bundle ships the cookbook recipes/applications and per-integration docs its SKILL.md already advertised. Fixes the ghost-path index described in #2641. Regeneration is drift-free (verify-generated-files passes) and link validation passes; bundle grows from ~85 to 168 files.

Fixes #2641
2026-07-10 16:08:40 -04:00
Cyprian Kowalczyk b52feb305e fix(consolidation): normalize dedup action case/whitespace before validation (#2611) 2026-07-10 16:06:56 -04:00
558b2f8b67 fix(llm): raise OpenRouter Qwen3 verification budget (#2633)
* fix(llm): raise OpenRouter Qwen3 verify budget

* test(llm): apply response hardening lint fixes

* fix(llm): generalize verification token budget

---------

Co-authored-by: r266-tech <[email protected]>
Co-authored-by: Ben <[email protected]>
2026-07-10 15:48:32 -04:00
Evoandr266-tech 383f0caa16 deps(security): require LiteLLM 1.84.0 (#2651)
Co-authored-by: r266-tech <[email protected]>
2026-07-10 15:36:51 -04:00
Ben 16e8c4e216 Blog: one shared memory across Cursor, OpenClaw, and Vapi (#2655)
A first-person, day-in-the-life post on running three AI tools (code, chat,
voice) against a single Hindsight bank: a decision made in Cursor is recalled
by the OpenClaw Slack agent and the Vapi voice agent, because all three point
at the same bank id. Grounded in each integration's actual bank config.
Hub-and-spoke cover.
2026-07-10 15:12:54 -04:00
JiehoonKwak c6a1b507ba Fix Codex OAuth request identity (#2647) 2026-07-10 15:08:15 -04:00
Liam ZhangandBen cb4fe70b63 fix(trace): skip LLM trace writes during daemon shutdown/pre-init races (#2618)
* fix(trace): skip LLM trace writes during daemon shutdown/pre-init races

LLMTraceRecorder._safe_write and _attach_memory_ids produce spurious
WARNING logs during two race windows:

1. Pre-init: MemoryEngine.initialize() runs verify_llm() inside the
   parallel init gather before the DB backend pool is ready. The
   pool_getter returns a backend object that raises RuntimeError on
   acquire.

2. Shutdown: MemoryEngine.close() calls backend.shutdown() (sets
   _pool=None) before setting self._backend=None. Fire-and-forget trace
   tasks see a non-None backend whose internal pool is already closed,
   hitting either RuntimeError('not initialized') or
   InterfaceError('pool is closing').

Both are expected lifecycle states, not actionable errors. Fix:
- Add a getattr(pool, '_pool') None guard before the acquire attempt
- Downgrade 'not initialized' and 'pool is closing' exceptions to DEBUG
  in both _safe_write and _attach_memory_ids
- All other write failures still warn

Supersedes #2562 (closed without merge), which only covered the
pre-init RuntimeError path. This PR additionally covers the shutdown
'pool is closing' race and the _attach_memory_ids write path.

5 regression tests covering: pool=None, backend._pool=None,
pool-is-closing, unexpected error (still warns), and
_attach_memory_ids with _pool=None.

* style: ruff format test_llm_trace.py

---------

Co-authored-by: Ben <[email protected]>
2026-07-10 14:08:49 -04:00
handnewbandBen 408d7c34c8 fix: handle FK violation in observation_history during parallel consolidation (#2620)
* fix: handle FK violation in observation_history during parallel consolidation

Wrap the INSERT into observation_history with a try/except for
ForeignKeyViolationError. Under parallel/batched consolidation, one
batch may delete an observation while another writes its history,
causing a race condition. Instead of failing the entire consolidation
task, log a warning and skip the history entry.

Also adds the missing  needed to catch the specific
exception type.

Closes #2597
Closes #2506

* test: regression for observation_history FK race (#2597, #2506)

---------

Co-authored-by: Ben <[email protected]>
2026-07-10 11:12:04 -04:00
B HicksandClaude Opus 4.7 7f2df54e01 feat(anthropic): implement the batch API interface via Message Batches (50% token discount) (#2628)
The engine's batch path (retain fact extraction, gated on
retain_batch_enabled) has been available to the OpenAI-compatible and Gemini
providers but not Anthropic — AnthropicLLM implemented none of the
LLMInterface batch methods, so supports_batch_api() returned False and the
gate hard-failed.

Implement all four methods against Anthropic's Message Batches API, which
bills every token at 50% of standard price:

- submit_batch translates the engine's OpenAI-JSONL-shaped entries into
  Messages batch requests, mirroring call()'s conversion rules: system
  messages fold into the system param, max_completion_tokens -> max_tokens,
  temperature is dropped (the sync path never sends it either), and
  response_format json_schema becomes a forced tool_use tool when strict
  (native constrained decoding, issue #1002) or a system-prompt schema
  injection otherwise. Operator extra_body params merge directly (batch
  params are the raw Messages body).
- get_batch_status maps processing_status onto the OpenAI vocabulary the
  engine's poll loop speaks: "ended" -> "completed" (per-request failures
  surface in results, matching OpenAI's completed-with-errors semantics),
  non-terminal states pass through; request_counts are aggregated to
  total/completed/failed.
- retrieve_batch_results renders succeeded messages as
  choices[0].message.content (forced-tool JSON re-serialized as the content
  string) with OpenAI-keyed usage, and errored/canceled/expired entries as
  per-result errors.

8 new tests covering translation in both directions, status mapping, and the
not-ended guard; existing batch-path and Anthropic provider suites pass
unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-07-10 10:57:01 -04:00
Ben 1758d8510b release(github-copilot): v0.1.0 2026-07-10 10:49:53 -04:00
B HicksandClaude Opus 4.7 7c3a5619f9 feat(anthropic): prompt caching via inline cache_control markers (#2629)
The Anthropic provider sent no cache_control at all, so every call paid full
input price on content the engine resends verbatim: fact extraction reuses
the same system prompt across every chunk, and the reflect agent loop resends
the entire growing conversation on each of its (up to
HINDSIGHT_API_REFLECT_MAX_ITERATIONS) iterations. Anthropic cache reads bill
at ~10% of base input price.

Implement the "inline-marker provider" strategy that
LLMInterface.get_or_create_cached_prefix already documents for Anthropic —
no engine changes, no new config:

- call() and call_with_tools() render the system prompt as a block list with
  a cache_control breakpoint (a prefix match, so tools + system cache
  together); schema text-injection happens before marking and lands inside
  the cached block.
- call_with_tools() additionally marks the final message content block, so
  each agent-loop request's end-marker becomes the next iteration's cache
  read point. 2 of the 4 allowed breakpoints used.

Marking is safe unconditionally: below the model's minimum cacheable prefix
the marker is silently ignored (no write premium), and cache_read_input_
tokens already flows through _usage_from_anthropic_response into metrics.

One existing assertion updated for the representation change
(test_non_strict_keeps_text_injection_fallback checked a substring on system
as a string; the schema-in-prompt behavior itself is unchanged and still
covered). 5 new tests pin the marker placement on both entry points.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-07-10 10:39:16 -04:00
Ben 2b5b47f97d Blog: Give Aider a Memory That Outlives the Session (#2642)
* Blog: give Aider a persistent, cross-session memory

Add a post on hindsight-aider, the drop-in wrapper that recalls project
memory before an Aider session (via a --read context file) and retains the
transcript after, scoped per git repo. Grounded in the v0.1.1 integration
source. Git-diff cover.

* Blog: use Aider-branded cover (real logo, brand green, VT220 font)
2026-07-09 15:59:35 -04:00
Ben b2508bf2d6 release(claude-code): v0.7.4 2026-07-09 15:54:05 -04:00
Ben b0fb1111ec fix(claude-code): skip primary + duplicate banks in recallAdditionalBanks (#2625)
The additional-banks recall loop recalled every entry with no dedup against
the resolved primary, so bidirectional cross-bank setups (primary listed in
recallAdditionalBanks) re-recalled the primary on every prompt — a wasted
recall call plus duplicate context. Guard the loop with a seen-set seeded with
the primary bank; also dedups repeated entries. Fixes #2604.
2026-07-09 15:48:53 -04:00
Nick Old 4bf126bf52 fix(claude-code): isolate MCP server cwd (#2635) 2026-07-09 15:47:51 -04:00
Evoandr266-tech e4449326e6 fix(reflect): tolerate null-like tool integer limits (#2639)
Co-authored-by: r266-tech <[email protected]>
2026-07-09 15:36:11 -04:00
Sanderhoff-alt 7bab4db28d fix(recall): decouple temporal seed threshold (#2595)
Keep recall min_scores.semantic scoped to the semantic retrieval arm.

Temporal retrieval uses embeddings only to choose time-window entry
points. Reusing the request-level semantic floor there made temporal
recall unexpectedly narrower.

Callers that only wanted to prune weak semantic matches could also
narrow temporal recall. That made the min_scores contract surprising
and inconsistent with graph seed selection.

Use the temporal entry-point default instead. Semantic and BM25 request
floors remain unchanged.
2026-07-09 10:28:20 -04:00
e29ee58603 fix(ollama): make native num_ctx opt-in (#2589)
* fix(ollama): make native num_ctx opt-in

* docs(ollama): add HINDSIGHT_API_LLM_OLLAMA_NUM_CTX to .env.example

* fix(config): keep Ollama num_ctx optional for direct config construction

---------

Co-authored-by: r266-tech <[email protected]>
Co-authored-by: Ben <[email protected]>
2026-07-09 10:25:21 -04:00
Evo 82e67315a4 fix(worker): complete successful operations (#2608) 2026-07-08 14:35:28 -04:00
DK09876andClaude Opus 4.8 2e82edee14 docs(oracle): document the required schema, dimension matching, and start-oracle re-run (#2615)
Verifying the Oracle guide end-to-end surfaced three setup steps that weren't
documented and that block a first-time deployment:

- HINDSIGHT_API_DATABASE_SCHEMA must be set to the Oracle schema user. The
  default `public` is a PostgreSQL notion and makes migrations fail with
  ORA-01435. Added it to the configure step (with a warning), the quick start,
  the config reference table, and troubleshooting.
- Migrations must run with the same embedding dimension as the serving model,
  or retain fails with ORA-51803. Added a warning to the migrate step and a
  troubleshooting row (including the --embedding-dimension resize path).
- The dev quick-start container can report a provisioning error on a cold
  start's first run; noted that re-running the idempotent script succeeds.

Mirrored into versioned_docs/version-0.8 and regenerated the docs skill.


Claude-Session: https://claude.ai/code/session_01PginSDrapXsoDd6gN5Pszo

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 20:35:16 -07:00
DK09876andClaude Opus 4.8 0338ab4d73 fix(oracle): don't await the synchronous cursor.close() in _set_session_schema (#2613)
oracledb's AsyncCursor.close() is not a coroutine, so awaiting it raised
"object NoneType can't be used in 'await' expression" on every acquire()
under a non-public schema. This broke the database health check and all
retain/recall/reflect operations on Oracle whenever a non-public schema was
active — which is the norm on Oracle, since a schema is a user and the
default `public` schema does not exist there.

Drop the erroneous await. Add unit regression tests (no live Oracle needed —
a fake cursor whose close() is synchronous, exactly like oracledb) covering
both the non-public path (previously raised TypeError) and the public no-op
path. These run in the standard test suite, unlike the label-gated Oracle
integration job.


Claude-Session: https://claude.ai/code/session_01PginSDrapXsoDd6gN5Pszo

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 16:49:51 -07:00
DK09876andClaude Opus 4.8 f4106fff55 docs: add Oracle Database setup guide (#2612)
* docs: add Oracle Database setup guide

Hindsight supports Oracle Database 23ai as a storage backend, but the docs
only mentioned it in passing — a one-paragraph note on the Storage page and a
couple of Configuration reference rows, with no `oracle+oracledb://` example
anywhere. This adds a dedicated Oracle Database page under Hosting.

The guide covers requirements (Oracle 23ai, the ASSM-tablespace requirement
for VECTOR columns, Oracle Text / CTXAPP), installing the python-oracledb
driver, a local quick start via scripts/dev/start-oracle.sh, production
provisioning SQL + connection URL + env vars + migrations, a config reference,
the differences from PostgreSQL, and troubleshooting. Content is grounded in
the CI Oracle job, the dev script, and the backend code.

Registered in the sidebar and cross-linked from Storage and Configuration.
Regenerated the docs agent-skill and mirrored the change into
versioned_docs/version-0.8 so it ships on the currently-served version.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01PginSDrapXsoDd6gN5Pszo

* docs(oracle): correct managed-service note, add connection caveat

The connection layer builds the Oracle DSN from the URL as a plain
host:port/service_name descriptor — wallet-based mTLS, TLS/TCPS, and TNS
aliases / full connect descriptors are not wired up. The previous "Least
privilege" note implied Oracle Autonomous Database works via an
ADMIN-provisioned user, which is misleading since ADB defaults to wallet/mTLS.

- Reworded the managed-service note to drop the specific ADB claim while
  keeping the accurate requirement (ASSM tablespace + CTXAPP).
- Added an "Easy Connect only" warning documenting that wallet/mTLS/TLS and
  TNS descriptors are unsupported, and that transport encryption must be
  handled at the network layer.

Applied to the current and version-0.8 copies; regenerated the docs skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01PginSDrapXsoDd6gN5Pszo

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 14:58:03 -07:00
BenandClaude Opus 4.8 d4f700ed22 Blog: Give Zed's AI Assistant a Persistent Memory (#2598)
* Blog: persistent memory for the Zed editor (hindsight-zed v0.1.0)

New post on the Zed integration: wires Zed's Agent Panel to the Hindsight MCP
server (recall/retain/reflect) plus a global AGENTS.md rule, so the assistant
remembers decisions and conventions across sessions. Grounded in the v0.1.0
source; em-dash-free. Adds a series-style cover.

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

* Blog: update Zed install to the Node CLI (npx), per #2599

hindsight-zed is now a zero-dependency Node CLI: `npx hindsight-zed init`
(or `npm install -g`). Node.js only, no Python. Mechanism unchanged.

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

* Blog: swap Zed cover to the typographic "Memory for Zed" poster

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 16:31:25 -04:00
Ben 041f0f13f4 release(zed): v0.2.0 2026-07-07 16:02:06 -04:00
9dee1c594b refactor(zed): port setup CLI to Node, drop the Python dependency (#2599)
* refactor(zed): port setup CLI to Node, drop the Python dependency

The Zed integration is configuration-only — it writes the `context_servers`
entry into Zed's settings.json and a recall/retain rule into AGENTS.md — and the
MCP server it configures runs via `npx mcp-remote`, so Node.js was already a hard
requirement. Requiring Python *as well* just to write two config files meant
users needed two runtimes.

Port the `hindsight-zed` CLI to a zero-dependency Node CLI so the integration
needs only Node:

- Node CLI under `src/` + `bin/hindsight-zed.js`, shipped via `package.json`
  (matches the existing TypeScript integrations; release-integration.yml already
  detects package.json for npm publishing).
- Behavior-preserving: same commands (`init`/`status`/`uninstall`), flags,
  `--print-only`, env/file/flag config resolution, JSONC-safe settings edits,
  and fenced AGENTS.md rule block.
- Tests ported to Node's built-in runner (`node --test`) — 21 tests.
- CI (`test.yml`) updated to run `npm test` on Node 22 instead of pytest.
- Removes the Python package (`hindsight_zed/`, `pyproject.toml`, `uv.lock`,
  Python `tests/`).

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

* fix(zed): make the Node package publishable by the release workflow

The release workflow classifies any integration with a package.json as
`type=typescript` and unconditionally runs `npm ci` + `npm run build` in
the integration dir. This zero-dependency, no-build JS package had neither,
so `integrations/zed/v*` would fail at release time (invisible in test CI,
which only runs `npm test`):

- add a no-op `build` script so `npm run build` succeeds
- commit package-lock.json so `npm ci` succeeds (it refuses to run without
  one, even with zero deps); lockfile has no node_modules entries, so
  check-integration-lockfiles.sh passes trivially
- drop the stray settings.json (a local `init` scaffold accidentally
  committed) and gitignore it

Verified locally: node --test (21/21), npm ci, npm run build, and
npm publish --dry-run all pass; tarball ships only bin/src/README.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW

* docs(zed): update setup to Node/npx (drop pip install)

* refactor(zed): scope npm package as @vectorize-io/hindsight-zed

Match the scoped-name convention of the other TS integrations
(@vectorize-io/hindsight-ai-sdk, -chat, -openclaw). CLI/bin command stays
'hindsight-zed'; npx/global-install references updated to the scoped name.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: DK09876 <[email protected]>
2026-07-07 16:01:33 -04:00
Ben a1ebb2d9c6 feat(llm): recover outer JSON span when fence stripping yields non-JSON (#2610)
Grafts the parse-validated fallback from #2557 onto the line-based fence
stripper merged in #2563: after stripping, if the candidate is not valid
JSON (partial/absent fence, prose-wrapped or truncated output), fall back
to the outermost parseable {..}/[..] span. Never returns a worse candidate
than the raw content.
2026-07-07 15:44:02 -04:00
Parafee41 06ddf041e5 fix(minimax): disable thinking by default (#2558) 2026-07-07 15:27:50 -04:00
poog26andBen d251fcb7d2 Fix _strip_code_fences truncating JSON when content contains inner backticks (#2563)
* Fix _strip_code_fences truncating JSON when content contains inner backticks

The old implementation used content.split('')[0] to strip
markdown code fences from LLM responses. This finds the FIRST occurrence of '''
after the opening fence — so when the extracted JSON itself contains literal
triple-backtick characters (e.g. facts about code fence formatting), the split
matches those inner backticks and truncates the JSON mid-string.

Replace with line-based fence detection that only matches fences at line
boundaries per the markdown spec. Inner backticks inside JSON string values
are preserved since they aren't at line boundaries.

* test(llm): cover inner-backtick fence stripping regression

---------

Co-authored-by: Ben <[email protected]>
2026-07-07 15:25:55 -04:00
Parafee41 e839c65537 fix(cli): set default user agent (#2564) 2026-07-07 15:12:18 -04:00
Evoandr266-tech 0cde79b831 fix(consolidation): default invalid dedup actions to keep (#2565)
Co-authored-by: r266-tech <[email protected]>
2026-07-07 15:04:04 -04:00
Ben 8767a518db docs(skill): sync configuration reference for BM25 term cap (#2609) 2026-07-07 15:01:21 -04:00
Parafee41 10ed288d80 build control plane client dependency (#2566) 2026-07-07 14:48:40 -04:00
7143684a81 feat(recall): add opt-in BM25 query term cap (#2567)
* Add opt-in BM25 query term cap

* docs(config): document HINDSIGHT_API_BM25_MAX_QUERY_TERMS

---------

Co-authored-by: r266-tech <[email protected]>
Co-authored-by: Ben <[email protected]>
2026-07-07 14:41:02 -04:00
Nick Old 11da432db2 Fix chunk delete deadlock ordering (#2570) 2026-07-07 14:28:43 -04:00
ishanmalikandishanmalik 73d3231bbd fix(api): accept verbatim extraction mode in bank manifests (#2576)
Co-authored-by: ishanmalik <[email protected]>
2026-07-07 14:06:35 -04:00
Ben 59d825dfca release(opencode): v0.2.7 2026-07-07 13:50:23 -04:00
Sanderhoff-alt ae7099fd02 fix(opencode): install plugin SDK at runtime (#2574)
The OpenCode plugin imports @opencode-ai/plugin/tool from its
built dist entrypoint, so the package must be present in
OpenCode's isolated plugin cache. Keeping it only as a peer
dependency lets npm skip it during plugin installation, which can
make the plugin fail to load with ERR_MODULE_NOT_FOUND.

Move @opencode-ai/plugin into dependencies and remove the
peer-only declaration. Keep @vectorize-io/hindsight-client as a
runtime dependency, and sync package-lock.json so npm installs the
cache tree needed by OpenCode.

Verified with npm run build, npm pack --json, and a local opencode
plugin install from the generated tarball. The generated cache
contained both runtime dependencies and direct import of the
plugin entrypoint succeeded.
2026-07-07 13:49:47 -04:00
Ben 56db6d7cf6 release(codex): v0.3.1 2026-07-07 13:47:50 -04:00
Ben 0eb52762ae release(claude-code): v0.7.3 2026-07-07 13:47:12 -04:00
ishanmalikandishanmalik e93c560288 feat(integrations): add recall score floors (#2575)
Co-authored-by: ishanmalik <[email protected]>
2026-07-07 13:44:42 -04:00
Evo 1f213d00f7 fix(api): allow clearing memory occurred dates with null (#2607) 2026-07-07 13:42:19 -04:00
Parafee41 8f51f99dde fix(retain): preserve JSON chunks during output retry (#2579) 2026-07-07 11:34:24 -04:00
Sanderhoff-alt 5cc1482a72 fix(memory): return metadata from memory browse endpoints (#2583)
The list and get memory-unit paths selected tags and timing fields
but skipped the memory_units metadata column, so metadata retained
on facts was invisible outside recall.

Select and serialize metadata for live and invalidated memory units,
add a curation regression test for both paths, and update docs plus
OpenAPI examples.
2026-07-07 11:11:04 -04:00
Chris Bartholomew 639d84ad32 fix(parsers): coerce non-bytes buffers before the UTF-8 charset probe in markitdown (#2586)
* fix(parsers): coerce non-bytes buffers before the UTF-8 charset probe

MarkitdownParser._utf8_stream_info() is type-hinted file_data: bytes and calls
file_data.decode('utf-8') to check whether a text file is valid UTF-8 before
handing markitdown an explicit charset hint. But callers may pass a
buffer-protocol object that is not a concrete bytes (a memoryview, or a
native/Rust-backed buffer), which has no .decode — raising
AttributeError: '...' object has no attribute 'decode' and failing every
text-file parse (.txt/.json/.md/.csv/.html/...).

The temp-file write in _convert_sync a few lines up already relies only on the
buffer protocol, so the decode probe was the sole spot assuming concrete bytes.
Coerce via bytes(file_data) before the probe. Adds a regression test with a
buffer-only object (no .decode).

* test(parsers): use memoryview stand-in for the non-bytes buffer case

The previous fixture defined a PEP 688 __buffer__ class, which bytes() only
recognizes on Python 3.12+; on 3.11 the CI shard raised
'TypeError: cannot convert ... object to bytes'. Use a memoryview instead — it
has no .decode and bytes(memoryview) works on every supported version, so the
test stays portable while still exercising the coercion path.
2026-07-07 11:04:22 -04:00
Evoandr266-tech 1c74f795a6 docs(eve): sync assistant reply default (#2588)
Co-authored-by: r266-tech <[email protected]>
2026-07-07 10:34:59 -04:00
Parafee41 b4f9fbe1b5 refresh search vector on memory curation (#2552) 2026-07-07 10:13:17 -04:00
Evoandr266-tech b992ba996d fix(agent-sdk): release recall token fix as 0.1.1 (#2596)
Co-authored-by: r266-tech <[email protected]>
2026-07-07 09:54:00 -04:00
Ben f00d3c7f66 Blog: Eve automatic memory (hindsight-eve v0.2.0) (#2584)
* Blog: Eve automatic memory (hindsight-eve v0.2.0)

New post covering the v0.2.0 rewrite of the Vercel Eve integration: memory
is now automatic (instructions resolver recalls before each turn, hook
retains after) with no model-called memory tool. Supersedes the v0.1 draft
in #2480. Adds cover + three demo screenshots (teach -> observation -> recall).
2026-07-06 14:57:48 -04:00
Ben e97b615547 release(eve): v0.2.1 2026-07-06 14:08:30 -04:00
Ben 29cc1d7fdc feat(eve): retain the assistant reply by default (#2585)
Flip `includeAssistantReply` to default `true` so the auto-retain hook stores
both the user's message and the assistant's reply, not just the user's message.
The assistant's reply is usually where the answer lives (the decision, the
solution, the code), and this matches every other Hindsight integration that
does automatic retain:

- agent-framework (same provider/after_run pattern as eve): include_input +
  include_response both hardcoded true
- opencode: retainMode "full-session" (user + assistant) by default
- claude-code: retainRoles ["user", "assistant"] by default

eve was the only auto-retain integration defaulting to user-only. Set
`includeAssistantReply: false` to keep the old behavior.

Updates JSDoc, README, and repurposes the "user-only by default" tests to
assert the new default (both), with the opt-out (false) still covered.
2026-07-06 14:04:35 -04:00
Ben 016b5f0363 release(eve): v0.2.0 2026-07-03 11:06:47 -04:00
Ben dd7e252452 feat(eve): auto-memory mode (v0.2.0) — no model tool-calling (#2527)
Replace the MCP-connection helper with automatic long-term memory backed by
Hindsight's REST API. Memory no longer depends on the model choosing to call a
tool (which proved unreliable — the model would reach for bash, a subagent, or
just acknowledge a fact without saving it).

Two authored files now give an Eve agent memory that just works:
- agent/instructions/hindsight.ts -> hindsightMemory(): a defineDynamic
  instructions resolver that recalls the user's stored memory and injects it as
  a system message before each turn.
- agent/hooks/hindsight.ts -> hindsightRetainHook(): a defineHook that retains
  the user message + assistant answer after each turn.

Pure core (HindsightRestClient, resolver, turn-pairing, recall formatting) is
split from the eve-importing wrappers and unit-tested with a mocked fetch.
Config via HINDSIGHT_API_KEY / HINDSIGHT_API_URL / HINDSIGHT_BANK_ID. Recall is
profile-based (eve's instruction resolver can't see the live user message).
Feedback-loop guard fences injected context so recalled facts are never
re-retained. Docs + integrations.json updated; bumped to 0.2.0 (breaking).
2026-07-03 11:02:54 -04:00
Ben fda1a77f70 Add architxt + Hindsight community blog post (#2526)
Community-contributed integration post (by Gareth Cooper) on architxt's
Temporal Mosaic: turning fragmented enterprise architecture documents into
a queryable, current-state view backed by Hindsight. Includes 5 product
screenshots + a co-branded cover, and registers the author in authors.yml.
2026-07-03 09:26:01 -04:00
Nicolò Boschi 0accef8e98 test(retain): add missing llm_temperature_retain to _build_request_body mock (#2537)
`_build_request_body` reads `config.llm_temperature_retain` (added by the
per-operation temperature work, #2459), but test_batch_request_body_strict_
follows_config's SimpleNamespace config never set it, so the test raised
`AttributeError: 'SimpleNamespace' object has no attribute
'llm_temperature_retain'`. It only fails on PRs that touch hindsight-api-slim;
main hides it via path-filtering, so it went unnoticed.

Set it to None (temperature omitted) so the test still asserts purely on the
`strict` flag it targets.
2026-07-03 14:05:31 +02:00
Nicolò Boschi c77e2368de feat(control-plane): animate the memories constellation & open memories in a dialog (#2536)
Constellation (memories + entities views):
- Ambient motion so the star map feels alive: slow per-node drift, a size
  pulse and brightness twinkle (each desynchronized by an id-derived phase),
  a calm breathing shimmer across idle links, and twinkling hub halos.
- On hover, a bead of light travels each of the node's links, so connections
  read as live signal paths rather than static lines.
- Re-measure the canvas via ResizeObserver when its container reflows (e.g.
  the Fullscreen toggle / layout changes) — window "resize" alone missed
  container-only changes, so CSS stretched the old bitmap and squeezed text.

Memories (data) view:
- Drop the right-hand control/detail side panel. Clicking a memory node now
  opens the same rich MemoryDetailModal the table/timeline use.
- Move the constellation controls (Color by, Group by scope, Link types) into
  an inline row above the graph, next to the view toggle — giving the star map
  full width.
2026-07-03 11:46:21 +02:00
Nicolò Boschi 38ef0247c2 fix(curation): drop archive search_vector column, recompute on revert (#2503) (#2514)
The curation archive (invalidated_memory_units) is a `LIKE memory_units`
clone with no index. It carried a `search_vector` column purely as a passive
copy in the invalidate/revert row-move — nothing ever reads it (no text-search
index, and recall/list/get/export all exclude it). But its type is fixed at
tsvector by the clone, while `ensure_text_search_extension` reconciles
`memory_units.search_vector` to text/bm25vector on non-native backends
(pgroonga / pg_textsearch / pg_search / vchord). The archive was never
reconciled, so the curation INSERT ... SELECT round-trip failed:

    column "search_vector" is of type tsvector but expression is of type text

This is the exact situation `embedding` was in (#2209): a config-derived,
recall-only column that has no business on the cold archive. Fix it the same
way `embedding` was fixed (d4f6a8c2e1b3):

- Migration e7c3a9f1b2d5 drops search_vector from invalidated_memory_units
  (PG + Oracle), so there is no column left to mismatch.
- The curation move omits search_vector from arch_cols (alongside embedding),
  so invalidate/revert never copy it.
- On revert, search_vector is recomputed from the row's own text/context/
  text_signals using the *current* text-search backend — right next to the
  existing embedding recompute. This is more correct than the old verbatim
  copy, which could restore a stale/wrong-type vector if the backend changed
  while the fact sat archived.

The per-backend search_vector SQL is extracted into pg_search_vector_expr as a
single source of truth shared by insert and revert (also collapses the three
near-identical insert query blocks into one). pgroonga/pg_textsearch/pg_search
index base columns directly and leave search_vector empty, so the expression is
None for them and the column is simply not written.

Tests: extend the curation suite to assert the archive drops search_vector and
that revert repopulates it (native); add fast unit tests for
pg_search_vector_expr and the per-backend insert column shape.
2026-07-03 11:44:13 +02:00
Parafee41 a158b819f3 fix(control-plane): keep memory filters visible on empty results (#2532) 2026-07-03 09:27:41 +02:00
illidanandillidan 381963c28a Fix JSON viewer unicode output display (#2531)
Co-authored-by: illidan <[email protected]>
2026-07-03 09:27:18 +02:00
BenandBen ba158c9cdb Add Devin Desktop blog cover image (#2524)
Co-authored-by: Ben <[email protected]>
2026-07-02 14:45:05 -04:00
Ben 767a2c0061 Blog: Devin Desktop persistent memory (formerly Windsurf) (#2483)
* Add Devin Desktop persistent memory blog post

Integration walkthrough for hindsight-devin-desktop (Devin Desktop, formerly
Windsurf): persistent memory via a remote MCP server plus an always-on
.devin/rules rule. Supersedes the earlier Windsurf post (same product,
renamed by Cognition in June 2026).
2026-07-02 09:47:40 -04:00
Nicolò Boschi 36334f27a1 refactor(control-plane): drop the Graph view from memories (#2517)
* refactor(control-plane): drop the Graph view from memories

Removes the Cytoscape-based "Graph" visualization from the memories views,
leaving Constellation, Table, and Timeline. The Graph view was the only
consumer of cytoscape, cytoscape-fcose, and the slider UI control.

- Delete the Graph2D component (src/components/graph-2d.tsx); move the
  shared graph data model + API-response converter (still used by the
  Constellation and entities views) into src/components/graph-data.ts.
- Remove the "graph" ViewMode, its tab button, render section, and
  graph-only state/effects (showLabels, maxNodes, linkStats) from
  data-view.tsx. The shared /api/graph data source that feeds all views
  is untouched.
- Drop cytoscape, cytoscape-fcose, @types/cytoscape and the now-orphaned
  @radix-ui/react-slider dependency + ui/slider.tsx.
- Remove the dead graph2d i18n namespace and graph-legend dataView keys
  from all locale catalogs (parity + used-keys tests stay green).

* chore: sync docs-skill openapi.json to 0.8.4

Pre-existing drift: the v0.8.4 release did not regenerate the bundled
docs-skill OpenAPI snapshot, leaving verify-generated-files red. Running
generate-docs-skill.sh bumps only the version string (0.8.3 -> 0.8.4).
Unrelated to the graph-view removal but required to make CI green.
2026-07-02 13:47:01 +02:00
Nicolò Boschi 6a479dddb9 fix(codex): implement strict_schema via forced tool call + repair invalid \escape (#2504) (#2513)
strict_schema was a dead no-op in codex_llm: structured output always went
through prompt-injected schema + raw json.loads on the model's free-form text.
Escape-heavy content (code, serial/CLI commands, Windows paths, regexes) makes
weaker models emit invalid \escape sequences, so every parse attempt fails and
retain/consolidation burn all retries and fail (same class as #1002/#2339).

- strict_schema=True now routes structured output through a single forced
  function tool (constrained decoding into the response schema), mirroring the
  Anthropic forced-tool_use fix (#2339). No prompt-injected schema, no
  json.loads on free-form text.
- The non-strict fallback and tool-argument parsing now repair invalid
  \escape sequences before giving up, stopping the deterministic retry storm
  for the default config.
2026-07-02 12:09:17 +02:00
Nicolò Boschi 7058d1aad7 fix(control-plane): show all mental models instead of capping at 100 (#2512)
* fix(control-plane): load all mental models instead of capping at 100

The mental models view fetched without a limit, so the dataplane applied
its default cap of 100. Any bank with more than 100 mental models silently
hid the rest — the dashboard's pagination and the files view both operate
over the full in-memory list, so nothing past the first 100 was reachable.

Thread limit/offset through the client and proxy route, and page through
the API in loadData() until a short page is returned, accumulating every
mental model for the bank.

* fix(control-plane): use page size of 100 for mental models paging
2026-07-02 12:02:38 +02:00
1111 changed files with 72307 additions and 12034 deletions
+6 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "hindsight",
"version": "0.7.2",
"version": "0.7.5",
"description": "Official Hindsight integrations for Claude Code",
"owner": {
"name": "vectorize-io"
@@ -11,6 +11,11 @@
"name": "hindsight-memory",
"description": "Automatic long-term memory for Claude Code via Hindsight",
"source": "./hindsight-integrations/claude-code"
},
{
"name": "hindsight-zcode",
"description": "No-MCP long-term memory for ZCode via Hindsight hooks",
"source": "./hindsight-integrations/zcode"
}
]
}
+13
View File
@@ -78,6 +78,11 @@ results = await asyncio.gather(*tasks, return_exceptions=True)
- **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).
### Database Locking
- **Never use PostgreSQL advisory locks** (`pg_advisory_lock`, `pg_try_advisory_lock`, `pg_advisory_xact_lock`, `pg_advisory_unlock`, …) in migrations, engine code, or anything else. Hindsight runs against connection poolers and managed/PG-compatible services where advisory locks are unreliable or unsupported: session-level locks silently leak or vanish when a pooler hands the session to another client, and callers can block forever on a lock the server never grants. Reject any new occurrence, including ones that look "safe" because they are transaction-scoped.
- The pre-existing usage in `hindsight_api/migrations.py` is grandfathered, not a precedent — it is tracked for removal. Don't copy it.
- Design the concurrency out instead of locking around it: give each process its own object to write (e.g. per-schema DDL rather than a shared `public.` object), make the operation idempotent, or use a real row/table constraint (`INSERT ... ON CONFLICT`, `SELECT ... FOR UPDATE` in a fixed order). See #2690 for a migration that reached for `pg_advisory_xact_lock` and had to be reverted.
### 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.
@@ -204,6 +209,14 @@ in `hindsight-api-slim/hindsight_api/config.py`):
The `test_bundled_template_matches_repo_root` sync test fails on drift; if the
root file changed without re-copying, flag it as a **must fix**.
### 11c. Check for advisory locks
Grep the diff for `advisory` (`git diff main...HEAD | grep -in advisory`). Any new
`pg_advisory_lock` / `pg_try_advisory_lock` / `pg_advisory_xact_lock` /
`pg_advisory_unlock` call is a **must fix** — see Database Locking above. Point the
author at the alternatives (per-process objects, idempotent DDL, row-level
constraints) rather than just asking them to drop the lock.
### 12. Review against other coding standards
Check the diff for violations of the standards listed above:
+78
View File
@@ -21,6 +21,25 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# HINDSIGHT_API_LLM_TEMPERATURE_REFLECT=0.9
# HINDSIGHT_API_LLM_TEMPERATURE_CONSOLIDATION=0.0
# Grammar-enforce structured output (json_schema strict) instead of the soft
# schema-in-prompt path. Helps weaker self-hosted models that emit prose preambles
# or invalid JSON. The global override below applies to every operation;
# per-operation overrides take precedence, in both directions -- set one to false
# to opt that operation out while the global flag is on.
# HINDSIGHT_API_LLM_STRICT_SCHEMA=false
# HINDSIGHT_API_LLM_STRICT_SCHEMA_RETAIN=true
# HINDSIGHT_API_LLM_STRICT_SCHEMA_REFLECT=true
# HINDSIGHT_API_LLM_STRICT_SCHEMA_CONSOLIDATION=true
# Some backends, including Bedrock Converse, reject JSON Schema maxItems.
# Disable it only for those backends; consolidation still enforces the cap.
# HINDSIGHT_API_LLM_SUPPORTS_MAX_ITEMS=true
# Diagnostic: on any LLM 4xx, log the exact assembled request ([LLM_4XX_DUMP]) --
# serialized request config (message bodies stripped) + capped per-message previews.
# For debugging otherwise-unreproducible rejected calls. Off by default.
# HINDSIGHT_API_LLM_DEBUG_DUMP_4XX=false
# Example: Anthropic Claude configuration
# HINDSIGHT_API_LLM_PROVIDER=anthropic
# HINDSIGHT_API_LLM_API_KEY=your-anthropic-api-key
@@ -59,6 +78,15 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# HINDSIGHT_API_LLM_BASE_URL=http://localhost:1234/v1
# HINDSIGHT_API_LLM_MODEL=qwen2.5-32b-instruct
# Example: Ollama local configuration (native provider)
# HINDSIGHT_API_LLM_PROVIDER=ollama
# HINDSIGHT_API_LLM_BASE_URL=http://localhost:11434/v1
# HINDSIGHT_API_LLM_MODEL=gemma3:12b
# Native Ollama context-window override (num_ctx). Leave unset to let Ollama use
# the model Modelfile / server default; set a positive integer only to force a
# specific context size (e.g. 16384 to keep the previous request behavior).
# HINDSIGHT_API_LLM_OLLAMA_NUM_CTX=16384
# Multi-LLM strategies: configure extra LLMs by index alongside the primary above,
# then pick a routing strategy. Unset = single primary LLM (default). Members are
# numbered from 1; indices must be contiguous. Each operation can override with a
@@ -80,6 +108,16 @@ HINDSIGHT_API_LOG_LEVEL=info
# Unset uses HINDSIGHT_API_RETAIN_CHUNK_SIZE as the structured-chunk limit.
# HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE=
# When true, a retain operation that hit any fact-extraction errors is marked
# 'failed' (not 'completed'), surfacing silently-dropped facts. Default false.
# HINDSIGHT_API_FAIL_ON_EXTRACTION_ERRORS=false
# Wall-clock ceiling (seconds) for one retain task in the worker. A retain that
# blocks indefinitely is cancelled and marked 'failed' — and so becomes
# retryable — instead of holding its worker slot until the process restarts.
# Set well above your slowest healthy retain; 0 disables. Default 3600.
# HINDSIGHT_API_RETAIN_WALL_TIMEOUT=3600
# Dry-run extraction preview endpoint (POST /memories/dry-run-extract). Enabled by default; it makes
# a real LLM call but stores nothing. Set to false to remove the endpoint (returns 404).
# HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=true
@@ -95,7 +133,10 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_READ_DATABASE_URL= # Optional read-replica URL. When set, recall queries (semantic, BM25, graph, temporal) flow through a separate pool against this URL, offloading the primary. Typically points to a read-only endpoint (CNPG's <cluster>-ro service or Aurora reader endpoint).
# HINDSIGHT_API_MIGRATION_DATABASE_URL= # Direct PostgreSQL URL for migrations (bypasses PgBouncer). Falls back to DATABASE_URL.
# HINDSIGHT_API_DATABASE_SCHEMA=public # PostgreSQL schema name (default: public)
# HINDSIGHT_API_DB_MAX_PARALLEL_WORKERS_PER_GATHER= # Optional cap on Postgres planner parallelism for this process's pool connections. Unset leaves the server default; 0 makes background/bulk queries run serially (useful on worker processes sharing a primary with latency-sensitive traffic).
# HINDSIGHT_API_MIGRATION_CONCURRENCY=1 # Tenant schemas to migrate concurrently (PG only, each in its own process; per-schema work stays sequential). Each worker has ~1-2s startup cost + uses ~3 DB connections, so it only pays off with many schemas (tens+) or slow migrations; keep concurrency*3 <= spare max_connections. Default: 1 (sequential).
# HINDSIGHT_API_OPERATION_RETENTION_DAYS=30 # Prune terminal operation rows, payloads, and metadata after this many days; 0 (the default) keeps them forever.
# HINDSIGHT_API_OPERATION_CLEANUP_BATCH_SIZE=1000 # Maximum expired terminal rows deleted per tenant schema in each cleanup cycle; must be positive.
# Vector Extension (Optional - uses pgvector by default)
# Options: "pgvector" (default), "vchord", "pgvectorscale" (DiskANN)
@@ -115,6 +156,11 @@ HINDSIGHT_API_LOG_LEVEL=info
# chinese_lindera/lindera(chinese), japanese_lindera/lindera(japanese),
# korean_lindera/lindera(korean), ngram(min,max), edge_ngram(min,max)
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER=
# Optional cap on the number of terms in the native PostgreSQL BM25 tsquery.
# Long queries OR-join every normalized token, which can match too much of a
# large bank. 0 (default) keeps the historical uncapped behavior; a positive
# value bounds only the native backend (other BM25 backends get the raw query).
# HINDSIGHT_API_BM25_MAX_QUERY_TERMS=0
# File Parser (Optional - uses markitdown by default)
# HINDSIGHT_API_FILE_PARSER=markitdown
@@ -133,6 +179,11 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
# For local provider:
# HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-small-en-v1.5
# Force CPU if local embeddings hit MPS/XPC instability on macOS:
# HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU=false
# Opt in to the Apple Silicon MPS GPU (off by default: MPS leaks memory under
# variable-length workloads). CUDA/XPU still auto-select regardless:
# HINDSIGHT_API_EMBEDDINGS_LOCAL_ALLOW_MPS=false
# 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
@@ -169,11 +220,28 @@ HINDSIGHT_API_LOG_LEVEL=info
# DeepSeek note: DeepSeek is supported for LLM calls, but not for embeddings.
# If using DeepSeek as LLM provider, keep embeddings on local/openai/cohere/google/etc.
# Embedding similarity thresholds. These defaults preserve the behavior calibrated
# for BAAI/bge-small-en-v1.5. Recalibrate each threshold independently when changing
# embedding models because cosine-similarity distributions are model-dependent.
# HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY=0.3
# HINDSIGHT_API_GRAPH_SEED_MIN_SIMILARITY=0.3
# HINDSIGHT_API_TEMPORAL_SEMANTIC_MIN_SIMILARITY=0.1
# HINDSIGHT_API_SEMANTIC_LINK_MIN_SIMILARITY=0.7
# HINDSIGHT_API_CONSOLIDATION_DEDUP_THRESHOLD=0.97
# Reranker Configuration (Optional - uses local by default)
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
# HINDSIGHT_API_RERANKER_PROVIDER=local
# Trusted gateway attribution (disabled by default). When enabled, remote
# reranker requests include X-Hindsight-Bank-Id with the current bank ID.
# HINDSIGHT_API_RERANKER_SEND_BANK_AS_HEADER=false
# For local provider:
# HINDSIGHT_API_RERANKER_LOCAL_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
# Force CPU if the local reranker hits MPS/XPC instability on macOS:
# HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU=false
# Opt in to the Apple Silicon MPS GPU (off by default: MPS leaks memory under
# variable-length workloads). CUDA/XPU still auto-select regardless:
# HINDSIGHT_API_RERANKER_LOCAL_ALLOW_MPS=false
# For TEI provider:
# HINDSIGHT_API_RERANKER_TEI_URL=http://localhost:8081
@@ -195,6 +263,16 @@ HINDSIGHT_API_LOG_LEVEL=info
# Expose async-operation queue + consolidation-backlog gauges on /metrics.
# Runs periodic per-schema COUNT queries on a background task (disabled by default).
# HINDSIGHT_API_METRICS_BACKLOG_ENABLED=true
#
# Runtime-stall observability (enabled by default). When a liveness probe fails,
# these tell you WHY: a blocked event loop vs DB connection-pool exhaustion.
# The loop watchdog logs the offending stack when the loop is unresponsive; the
# DB-pool acquire timing logs (and exposes hindsight.db.pool.waiting) when
# callers queue for a connection. Both are cheap; tune or disable if needed.
# HINDSIGHT_API_LOOP_WATCHDOG_ENABLED=false
# HINDSIGHT_API_LOOP_WATCHDOG_STALL_THRESHOLD_MS=1000
# HINDSIGHT_API_LOOP_WATCHDOG_POLL_INTERVAL_MS=250
# HINDSIGHT_API_DB_ACQUIRE_WARN_THRESHOLD_MS=1000
# -----------------------------------------------------------------------------
# Control Plane (Optional)
+169 -17
View File
@@ -41,6 +41,8 @@ jobs:
integrations-github-copilot: ${{ steps.filter.outputs.integrations-github-copilot }}
integrations-continue: ${{ steps.filter.outputs.integrations-continue }}
integrations-cursor-cli: ${{ steps.filter.outputs.integrations-cursor-cli }}
integrations-zcode: ${{ steps.filter.outputs.integrations-zcode }}
integrations-copilot-cli: ${{ steps.filter.outputs.integrations-copilot-cli }}
integrations-crewai: ${{ steps.filter.outputs.integrations-crewai }}
integrations-litellm: ${{ steps.filter.outputs.integrations-litellm }}
integrations-pydantic-ai: ${{ steps.filter.outputs.integrations-pydantic-ai }}
@@ -152,6 +154,8 @@ jobs:
- 'hindsight-integrations/continue/**'
integrations-cursor-cli:
- 'hindsight-integrations/cursor-cli/**'
integrations-copilot-cli:
- 'hindsight-integrations/copilot-cli/**'
integrations-crewai:
- 'hindsight-integrations/crewai/**'
integrations-litellm:
@@ -180,6 +184,8 @@ jobs:
- 'hindsight-integrations/cursor/**'
integrations-zed:
- 'hindsight-integrations/zed/**'
integrations-zcode:
- 'hindsight-integrations/zcode/**'
integrations-n8n:
- 'hindsight-integrations/n8n/**'
integrations-zapier:
@@ -283,6 +289,18 @@ jobs:
working-directory: ./hindsight-api-slim
run: uv build
# `uv build` only packages the source; it does not prove the dependency set
# resolves or that the code imports on this interpreter. Install into a fresh
# env and run a byte-compile + import smoke test so the matrix actually
# exercises each Python version (notably 3.14).
- name: Install and smoke-test on Python ${{ matrix.python-version }}
working-directory: ./hindsight-api-slim
run: |
uv venv --python ${{ matrix.python-version }} .venv-smoke
VIRTUAL_ENV=.venv-smoke uv pip install .
.venv-smoke/bin/python -m compileall -q hindsight_api
.venv-smoke/bin/python -c "import hindsight_api, hindsight_api.main, hindsight_api.config; from hindsight_api.engine import memory_engine, llm_wrapper; print('import OK')"
build-typescript-client:
needs: [detect-changes]
if: >-
@@ -520,22 +538,17 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Python
uses: actions/setup-python@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with:
python-version: '3.11'
- name: Install package and pytest
working-directory: ./hindsight-integrations/zed
# Installs the package (incl. the zstandard runtime dep) so the threads.db
# reader tests can decompress Zed's zstd blobs.
run: pip install -e . pytest
node-version: '22'
- name: Run tests
working-directory: ./hindsight-integrations/zed
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: python -m pytest tests/ -v -m "not requires_real_llm"
# Config-only integration with no dependencies — it uses Node's built-in
# test runner. The runtime MCP bridge is `npx mcp-remote` (Node), so this
# integration requires only Node.js (no Python).
run: npm test
test-omo-integration:
needs: [detect-changes]
@@ -702,6 +715,43 @@ jobs:
working-directory: ./hindsight-integrations/cursor-cli
run: uv run pytest tests -v
test-zcode-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-zcode == '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 zcode integration
working-directory: ./hindsight-integrations/zcode
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/zcode
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/zcode
run: uv run pytest tests -v
build-ai-sdk-integration:
needs: [detect-changes]
if: >-
@@ -1229,10 +1279,10 @@ jobs:
build-docs:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.docs == 'true' ||
needs.detect-changes.outputs.ci == 'true')
# Keep the production docs build as an unconditional PR check. OpenAPI
# generation used to build the site again inside verify-generated-files;
# running the existing job for every PR preserves that coverage without
# serializing two full Docusaurus builds in the generated-files check.
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -1836,6 +1886,27 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
# The Oracle 23ai `free` service image is large; combined with the Python ML
# deps (torch) it exhausts the runner's ~14 GB root disk, so uv fails to
# extract a wheel with "No space left on device (os error 28)".
# Only the cheap, high-yield reclaims are enabled — the Android/.NET/Haskell
# dirs plus swap are a few `rm -rf`s worth ~16-21 GB, which is ample headroom:
# - large-packages runs apt-get remove and costs minutes for little gain;
# - tool-cache would delete the preinstalled Python that actions/setup-python
# then has to re-download, making the job slower, not faster;
# - docker-images is pointless here (the Oracle service container is already
# running, so its image is in use and cannot be pruned anyway).
- name: Free Disk Space
uses: jlumbroso/free-disk-space@main
with:
tool-cache: false
android: true
dotnet: true
haskell: true
large-packages: false
docker-images: false
swap-storage: true
- name: Setup Oracle test user
# The SYSTEM tablespace uses manual segment space management which
# doesn't support VECTOR types. Create an ASSM tablespace and a
@@ -2196,6 +2267,27 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
# The Oracle 23ai `free` service image is large; combined with the Python ML
# deps (torch) it exhausts the runner's ~14 GB root disk, so uv fails to
# extract a wheel with "No space left on device (os error 28)".
# Only the cheap, high-yield reclaims are enabled — the Android/.NET/Haskell
# dirs plus swap are a few `rm -rf`s worth ~16-21 GB, which is ample headroom:
# - large-packages runs apt-get remove and costs minutes for little gain;
# - tool-cache would delete the preinstalled Python that actions/setup-python
# then has to re-download, making the job slower, not faster;
# - docker-images is pointless here (the Oracle service container is already
# running, so its image is in use and cannot be pruned anyway).
- name: Free Disk Space
uses: jlumbroso/free-disk-space@main
with:
tool-cache: false
android: true
dotnet: true
haskell: true
large-packages: false
docker-images: false
swap-storage: true
- name: Setup Oracle test user
run: |
pip install oracledb
@@ -2356,6 +2448,27 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
# The Oracle 23ai `free` service image is large; combined with the Python ML
# deps (torch) it exhausts the runner's ~14 GB root disk, so uv fails to
# extract a wheel with "No space left on device (os error 28)".
# Only the cheap, high-yield reclaims are enabled — the Android/.NET/Haskell
# dirs plus swap are a few `rm -rf`s worth ~16-21 GB, which is ample headroom:
# - large-packages runs apt-get remove and costs minutes for little gain;
# - tool-cache would delete the preinstalled Python that actions/setup-python
# then has to re-download, making the job slower, not faster;
# - docker-images is pointless here (the Oracle service container is already
# running, so its image is in use and cannot be pruned anyway).
- name: Free Disk Space
uses: jlumbroso/free-disk-space@main
with:
tool-cache: false
android: true
dotnet: true
haskell: true
large-packages: false
docker-images: false
swap-storage: true
- name: Setup Oracle test user
run: |
pip install oracledb
@@ -3481,6 +3594,43 @@ jobs:
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-copilot-cli-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-copilot-cli == '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 copilot-cli integration
working-directory: ./hindsight-integrations/copilot-cli
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/copilot-cli
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/copilot-cli
run: uv run pytest tests -v
test-crewai-integration:
needs: [detect-changes]
if: >-
@@ -4771,7 +4921,8 @@ jobs:
cd ../hindsight-embed && uv sync --frozen --index-strategy unsafe-best-match
- name: Run generate-openapi
run: ./scripts/generate-openapi.sh
working-directory: hindsight-dev
run: uv run generate-openapi
- name: Run generate-bank-template-schema
run: ./scripts/generate-bank-template-schema.sh
@@ -4909,6 +5060,7 @@ jobs:
- test-github-copilot-integration
- test-codex-integration
- test-cursor-cli-integration
- test-zcode-integration
- build-ai-sdk-integration
- test-ai-sdk-integration-deno
- test-opencode-integration
+1
View File
@@ -41,6 +41,7 @@ nltk_data/
logs/
.DS_Store
.sesskey
# Generated docs files
hindsight-docs/static/llms-full.txt
-1
View File
@@ -1 +0,0 @@
fcac2839-1db5-432f-91e1-c5dac07d7290
+30 -9
View File
@@ -41,28 +41,43 @@ RUN apt-get update && apt-get install -y \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir uv
# Copy dependency files and README (required by pyproject.toml)
# Copy the workspace lock and member metadata before source code so dependency
# installation stays cacheable while matching the versions tested in CI.
COPY pyproject.toml uv.lock ./
COPY hindsight-all/pyproject.toml ./hindsight-all/
COPY hindsight-api/pyproject.toml ./hindsight-api/
COPY hindsight-api-slim/pyproject.toml ./api/
COPY hindsight-api-slim/README.md ./api/
WORKDIR /app/api
COPY hindsight-all-slim/pyproject.toml ./hindsight-all-slim/
COPY hindsight-dev/pyproject.toml ./hindsight-dev/
COPY hindsight-clients/python/pyproject.toml ./hindsight-clients/python/
COPY hindsight-embed/pyproject.toml ./hindsight-embed/
RUN ln -s api hindsight-api-slim
# 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.
ENV UV_PROJECT_ENVIRONMENT=/app/api/.venv
RUN if [ "$INCLUDE_LOCAL_MODELS" = "true" ]; then \
uv sync --extra local-ml --extra embedded-db; \
uv sync --locked --package hindsight-api-slim --no-install-package hindsight-api-slim --extra local-ml --extra embedded-db; \
else \
uv sync --extra embedded-db; \
uv sync --locked --package hindsight-api-slim --no-install-package hindsight-api-slim --extra embedded-db; \
fi
# Copy source code (alembic migrations are inside hindsight_api/)
WORKDIR /app/api
COPY hindsight-api-slim/hindsight_api ./hindsight_api
# Install the local package (uv sync only installed dependencies, not the package itself)
RUN uv pip install -e .
# Install the local package from the same validated lock after source is present.
WORKDIR /app
RUN if [ "$INCLUDE_LOCAL_MODELS" = "true" ]; then \
uv sync --locked --package hindsight-api-slim --extra local-ml --extra embedded-db; \
else \
uv sync --locked --package hindsight-api-slim --extra embedded-db; \
fi \
&& uv pip check --python /app/api/.venv/bin/python
# =============================================================================
# Stage: SDK Builder (needed for Control Plane)
@@ -145,6 +160,8 @@ FROM python:3.11-slim AS api-only
WORKDIR /app
# Note: libicu version varies by Debian version - try common versions in order
# Runtime images use uv directly; remove pip build tooling after installation so
# vulnerable setuptools-vendored packages and wheel are not shipped in production.
RUN apt-get update && apt-get install -y \
curl \
procps \
@@ -154,7 +171,8 @@ RUN apt-get update && apt-get install -y \
libossp-uuid16 \
&& (apt-get install -y libicu72 2>/dev/null || apt-get install -y libicu74 2>/dev/null || apt-get install -y libicu76 2>/dev/null || true) \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir uv
&& pip install --no-cache-dir uv \
&& pip uninstall --yes setuptools wheel
RUN useradd -m -s /bin/bash hindsight
@@ -292,6 +310,8 @@ WORKDIR /app
# Install Node.js, curl, uv, and system dependencies
# Note: libicu version varies by Debian version - try common versions in order
# Runtime images use uv directly; remove pip build tooling after installation so
# vulnerable setuptools-vendored packages and wheel are not shipped in production.
RUN apt-get update && apt-get install -y \
curl \
procps \
@@ -303,7 +323,8 @@ RUN apt-get update && apt-get install -y \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir uv
&& pip install --no-cache-dir uv \
&& pip uninstall --yes setuptools wheel
RUN useradd -m -s /bin/bash hindsight
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.8.4
appVersion: "0.8.4"
version: 0.8.5
appVersion: "0.8.5"
keywords:
- ai
- memory
@@ -60,13 +60,13 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.name
{{- /* Inherit LLM config from api.env */}}
{{- range $key, $value := .Values.api.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- /* Worker-specific env vars */}}
{{- range $key, $value := .Values.worker.env }}
{{- /* Explicitly set port to override K8s service discovery env var (HINDSIGHT_API_PORT) */}}
- name: HINDSIGHT_API_PORT
value: {{ .Values.worker.service.targetPort | quote }}
{{- /* Inherit LLM config from api.env, then apply worker-specific env.
Merge (worker.env wins) so a key set in both does not emit a
duplicate env entry, which server-side apply rejects. */}}
{{- range $key, $value := merge (deepCopy (.Values.worker.env | default dict)) (.Values.api.env | default dict) }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-all",
"version": "0.8.4",
"version": "0.8.5",
"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.8.4"
version = "0.8.5"
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.8.4",
"hindsight-api-slim==0.8.5",
"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.8.4"
version = "0.8.5"
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.8.4",
"hindsight-api-slim[all]==0.8.5",
"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.8.4",
"hindsight-api-slim[local-llm]==0.8.5",
]
test = [
"pytest>=7.0.0",
+1 -1
View File
@@ -53,4 +53,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.8.4"
__version__ = "0.8.5"
+326 -29
View File
@@ -9,6 +9,7 @@ import io
import json
import logging
import zipfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -16,10 +17,12 @@ from typing import Any
import asyncpg
import typer
from ..config import DEFAULT_DATABASE_SCHEMA, HindsightConfig
from ..config import DEFAULT_DATABASE_SCHEMA, HindsightConfig, load_dotenv_for_entrypoint
from ..engine.memory_engine import _current_schema
from ..engine.retain.bank_utils import _vector_index_clause
from ..engine.schema import fq_table_explicit as _fq_table
from ..engine.transfer import export_bank
from ..engine.vector_index_health import SchemaVectorIndexResult, repair_vector_indexes
from ..extensions import TenantExtension, load_extension
from ..pg0 import parse_pg0_url, resolve_database_url
@@ -65,7 +68,88 @@ BACKUP_TABLES = [
"graph_maintenance_queue",
]
MANIFEST_VERSION = "1"
MANIFEST_VERSION = "2"
@dataclass(frozen=True)
class BackupColumn:
"""A PostgreSQL column shape required to decode a binary COPY stream."""
name: str
type_name: str
async def _table_columns(conn: asyncpg.Connection, schema: str, table: str) -> list[BackupColumn]:
rows = await conn.fetch(
"""
SELECT a.attname AS name, pg_catalog.format_type(a.atttypid, a.atttypmod) AS type_name
FROM pg_catalog.pg_attribute AS a
JOIN pg_catalog.pg_class AS c ON c.oid = a.attrelid
JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace
WHERE n.nspname = $1 AND c.relname = $2 AND a.attnum > 0 AND NOT a.attisdropped
AND a.attgenerated = ''
ORDER BY a.attnum
""",
schema,
table,
)
return [BackupColumn(name=row["name"], type_name=row["type_name"]) for row in rows]
async def _validate_restore_schema(
conn: asyncpg.Connection, manifest: dict[str, Any], schema: str
) -> dict[str, list[str]]:
"""Validate every COPY stream against the target before destructive work starts.
Type equality is an exact ``format_type`` string match. This is deliberately
stricter than binary-COPY wire compatibility (e.g. ``varchar`` and ``text``
share a binary format yet compare unequal here): we would rather fail a
genuinely-restorable backup with a clear, actionable error than silently risk
a subtle binary mismatch. Restores blocked this way can be recovered by
aligning the target schema.
"""
restore_columns: dict[str, list[str]] = {}
errors: list[str] = []
for table, table_manifest in manifest["tables"].items():
source_columns = [BackupColumn(**column) for column in table_manifest["columns"]]
target_by_name = {column.name: column for column in await _table_columns(conn, schema, table)}
missing = [column.name for column in source_columns if column.name not in target_by_name]
mismatched = [
f"{column.name} ({column.type_name} in backup, {target_by_name[column.name].type_name} in target)"
for column in source_columns
if column.name in target_by_name and target_by_name[column.name].type_name != column.type_name
]
if missing:
errors.append(f"{table}: target is missing backup columns {', '.join(missing)}")
if mismatched:
errors.append(f"{table}: incompatible column types: {', '.join(mismatched)}")
restore_columns[table] = [column.name for column in source_columns]
if errors:
details = "; ".join(errors)
raise ValueError(f"Backup schema is incompatible with target schema '{schema}': {details}")
return restore_columns
def _effective_backup_tables() -> list[str]:
"""Core backup tables plus any bank-scoped tables a loaded extension declares.
``BACKUP_TABLES`` covers only the tables core owns. An extension that
provisions its own bank-scoped tables (via ``TenantExtension``) declares
them through ``extra_bank_tables()`` so they aren't dropped on restore.
Extension tables are appended *after* the core set so restore's forward
COPY inserts them after their FK parents (e.g. ``banks``) and the reversed
TRUNCATE clears them before those parents.
"""
tables = list(BACKUP_TABLES)
tenant_extension = load_extension("TENANT", TenantExtension)
if tenant_extension is not None:
seen = set(tables)
for spec in tenant_extension.extra_bank_tables():
if spec.include_in_backup and spec.name not in seen:
tables.append(spec.name)
seen.add(spec.name)
return tables
async def _admin_connect(db_url: str) -> asyncpg.Connection:
@@ -76,7 +160,8 @@ async def _admin_connect(db_url: str) -> asyncpg.Connection:
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)
_pg0 = parse_pg0_url(db_url)
is_pg0, instance_name = _pg0.is_pg0, _pg0.instance_name
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
conn = await asyncpg.connect(await resolve_database_url(db_url))
@@ -85,8 +170,18 @@ async def _admin_connect(db_url: str) -> asyncpg.Connection:
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."""
async def _backup(
database_url: str,
output_path: Path,
schema: str = "public",
backup_tables: list[str] | None = None,
) -> dict[str, Any]:
"""Backup all tables to a zip file using binary COPY protocol.
``backup_tables`` defaults to the core ``BACKUP_TABLES``; callers pass the
extension-augmented list from ``_effective_backup_tables()``.
"""
backup_tables = backup_tables if backup_tables is not None else BACKUP_TABLES
conn = await asyncpg.connect(database_url)
try:
tables: dict[str, Any] = {}
@@ -103,14 +198,24 @@ async def _backup(database_url: str, output_path: Path, schema: str = "public")
# entities table was backed up.
async with conn.transaction(isolation="repeatable_read"):
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
for i, table in enumerate(BACKUP_TABLES, 1):
typer.echo(f" [{i}/{len(BACKUP_TABLES)}] Backing up {table}...", nl=False)
for i, table in enumerate(backup_tables, 1):
typer.echo(f" [{i}/{len(backup_tables)}] Backing up {table}...", nl=False)
buffer = io.BytesIO()
# Use binary COPY for exact type preservation
columns = await _table_columns(conn, schema, table)
# Pin the ordered columns into both the stream and manifest.
# PostgreSQL binary COPY does not encode column identities, so
# restore must validate this shape before truncating any data.
# asyncpg requires schema_name as separate parameter
await conn.copy_from_table(table, schema_name=schema, output=buffer, format="binary")
await conn.copy_from_table(
table,
schema_name=schema,
columns=[column.name for column in columns],
output=buffer,
format="binary",
)
data = buffer.getvalue()
zf.writestr(f"{table}.bin", data)
@@ -121,6 +226,7 @@ async def _backup(database_url: str, output_path: Path, schema: str = "public")
tables[table] = {
"rows": row_count,
"size_bytes": len(data),
"columns": [{"name": column.name, "type_name": column.type_name} for column in columns],
}
typer.echo(f" {row_count} rows")
@@ -132,8 +238,20 @@ async def _backup(database_url: str, output_path: Path, schema: str = "public")
await conn.close()
async def _restore(database_url: str, input_path: Path, schema: str = "public") -> dict[str, Any]:
"""Restore all tables from a zip file using binary COPY protocol."""
async def _restore(
database_url: str,
input_path: Path,
schema: str = "public",
backup_tables: list[str] | None = None,
) -> dict[str, Any]:
"""Restore all tables from a zip file using binary COPY protocol.
``backup_tables`` defaults to the core ``BACKUP_TABLES``; callers pass the
extension-augmented list from ``_effective_backup_tables()``. Tables named
here but absent from the archive are truncated then skipped for restore, so
a stale extension registration never leaves pre-restore rows behind.
"""
backup_tables = backup_tables if backup_tables is not None else BACKUP_TABLES
conn = await asyncpg.connect(database_url)
try:
with zipfile.ZipFile(input_path, "r") as zf:
@@ -142,29 +260,40 @@ async def _restore(database_url: str, input_path: Path, schema: str = "public")
if manifest.get("version") != MANIFEST_VERSION:
raise ValueError(f"Unsupported backup version: {manifest.get('version')}")
# Complete the compatibility check before entering the transaction
# that truncates tables. This turns historical schema drift into an
# actionable error without risking the target's existing data.
restore_columns = await _validate_restore_schema(conn, manifest, schema)
# Use a transaction for atomic restore - either all tables are
# restored or none are, preventing partial/inconsistent state.
async with conn.transaction():
typer.echo(" Clearing existing data...")
# Truncate tables in reverse order (respects FK constraints)
for table in reversed(BACKUP_TABLES):
for table in reversed(backup_tables):
qualified_table = _fq_table(table, schema)
await conn.execute(f"TRUNCATE TABLE {qualified_table} CASCADE")
# Restore tables in forward order
for i, table in enumerate(BACKUP_TABLES, 1):
for i, table in enumerate(backup_tables, 1):
filename = f"{table}.bin"
if filename not in zf.namelist():
typer.echo(f" [{i}/{len(BACKUP_TABLES)}] {table}: skipped (not in backup)")
typer.echo(f" [{i}/{len(backup_tables)}] {table}: skipped (not in backup)")
continue
expected_rows = manifest["tables"].get(table, {}).get("rows", "?")
typer.echo(f" [{i}/{len(BACKUP_TABLES)}] Restoring {table}... {expected_rows} rows")
typer.echo(f" [{i}/{len(backup_tables)}] Restoring {table}... {expected_rows} rows")
data = zf.read(filename)
buffer = io.BytesIO(data)
# asyncpg requires schema_name as separate parameter
await conn.copy_to_table(table, schema_name=schema, source=buffer, format="binary")
await conn.copy_to_table(
table,
schema_name=schema,
columns=restore_columns[table],
source=buffer,
format="binary",
)
# Refresh materialized view
typer.echo(" Refreshing materialized views...")
@@ -177,20 +306,22 @@ async def _restore(database_url: str, input_path: Path, schema: str = "public")
async def _run_backup(db_url: str, output: Path, schema: str = "public") -> dict[str, Any]:
"""Resolve database URL and run backup."""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
_pg0 = parse_pg0_url(db_url)
is_pg0, instance_name = _pg0.is_pg0, _pg0.instance_name
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
resolved_url = await resolve_database_url(db_url)
return await _backup(resolved_url, output, schema)
return await _backup(resolved_url, output, schema, backup_tables=_effective_backup_tables())
async def _run_restore(db_url: str, input_file: Path, schema: str = "public") -> dict[str, Any]:
"""Resolve database URL and run restore."""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
_pg0 = parse_pg0_url(db_url)
is_pg0, instance_name = _pg0.is_pg0, _pg0.instance_name
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
resolved_url = await resolve_database_url(db_url)
return await _restore(resolved_url, input_file, schema)
return await _restore(resolved_url, input_file, schema, backup_tables=_effective_backup_tables())
@app.command()
@@ -214,7 +345,7 @@ def backup(
manifest = asyncio.run(_run_backup(config.database_url, output, schema))
total_rows = sum(t["rows"] for t in manifest["tables"].values())
typer.echo(f"Backed up {total_rows} rows across {len(BACKUP_TABLES)} tables")
typer.echo(f"Backed up {total_rows} rows across {len(manifest['tables'])} tables")
typer.echo(f"Backup saved to {output}")
@@ -247,7 +378,7 @@ def restore(
manifest = asyncio.run(_run_restore(config.database_url, input_file, schema))
total_rows = sum(t["rows"] for t in manifest["tables"].values())
typer.echo(f"Restored {total_rows} rows across {len(BACKUP_TABLES)} tables")
typer.echo(f"Restored {total_rows} rows across {len(manifest['tables'])} tables")
typer.echo("Restore complete")
@@ -261,17 +392,17 @@ async def _run_migration(
"""Resolve database URL and run migrations for one schema or all discovered schemas."""
from ..migrations import run_migrations_for_schemas
is_pg0, instance_name, _ = parse_pg0_url(db_url)
_pg0 = parse_pg0_url(db_url)
is_pg0, instance_name = _pg0.is_pg0, _pg0.instance_name
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
resolved_url = await resolve_database_url(db_url)
config = HindsightConfig.from_env()
tenant_extension = load_extension("TENANT", TenantExtension)
if schema:
schemas = [schema]
else:
tenant_extension = load_extension("TENANT", TenantExtension)
schemas = [base_schema or DEFAULT_DATABASE_SCHEMA]
if tenant_extension:
tenants = await tenant_extension.list_tenants()
@@ -296,9 +427,36 @@ async def _run_migration(
ensure_extensions=ensure_extensions,
)
# After core migrations, provision any extension-owned bank-scoped tables
# per schema so extension schema evolves on the same lifecycle as core
# schema (rather than via a lazy first-request path).
if tenant_extension is not None:
await _provision_extra_bank_tables(resolved_url, schemas, tenant_extension)
return schemas
async def _provision_extra_bank_tables(
resolved_url: str, schemas: list[str], tenant_extension: TenantExtension
) -> None:
"""Run the tenant extension's table provisioner for each migrated schema.
Fires after core migrations complete so extension-owned bank tables are
created/evolved on the same lifecycle as core schema. A failure aborts the
migration command (and names the offending schema) rather than being
swallowed — provisioning is idempotent, so the operator can fix and re-run.
"""
for schema in schemas:
conn = await asyncpg.connect(resolved_url)
try:
await tenant_extension.provision_bank_tables(conn, schema)
except Exception as e:
typer.echo(f" Failed to provision extension tables for schema '{schema}': {e}", err=True)
raise
finally:
await conn.close()
@app.command(name="run-db-migration")
def run_db_migration(
schema: str | None = typer.Option(
@@ -353,6 +511,134 @@ def run_db_migration(
typer.echo(f"Database migrations completed successfully for {len(schemas)} schema(s)")
async def _resolve_schemas(base_schema: str | None) -> list[str]:
"""Base schema plus every discovered tenant schema, de-duplicated in order."""
schemas = [base_schema or DEFAULT_DATABASE_SCHEMA]
tenant_extension = load_extension("TENANT", TenantExtension)
if tenant_extension:
tenants = await tenant_extension.list_tenants()
schemas.extend(tenant.schema for tenant in tenants if tenant.schema)
return list(dict.fromkeys(schemas))
async def _run_repair_bank(
db_url: str,
*,
base_schema: str,
schema: str | None,
bank_id: str | None,
dry_run: bool,
) -> list[SchemaVectorIndexResult]:
"""Reconcile per-(bank, fact_type) vector index coverage over a raw connection.
A single autocommit connection is used because ``CREATE INDEX CONCURRENTLY``
(used by ``repair_vector_indexes``) cannot run inside a transaction block.
"""
schemas = [schema] if schema else await _resolve_schemas(base_schema)
index_clause = _vector_index_clause()
# Guarded by the command, but assert so this helper is never called for a
# backend without per-bank indexes.
assert index_clause is not None
conn = await _admin_connect(db_url)
try:
results = await repair_vector_indexes(conn, schemas, index_clause, dry_run=dry_run, bank_id=bank_id)
for result in results:
typer.echo(
f" schema '{result.schema}': {result.banks_scanned} bank(s) scanned, "
f"{result.already_present} present, {result.created} created, "
f"{result.skipped} to-create (dry-run), {result.failed} failed"
)
return results
finally:
await conn.close()
@app.command(name="repair-bank")
def repair_bank(
bank_id: str | None = typer.Option(
None,
"--bank",
"-b",
help="Bank id to repair. Mutually exclusive with --all.",
),
all_banks: bool = typer.Option(
False,
"--all",
help="Repair every bank in the base schema and all discovered tenant schemas.",
),
schema: str | None = typer.Option(
None,
"--schema",
"-s",
help="Limit to a single schema. Defaults to the base schema plus discovered tenant schemas.",
),
dry_run: bool = typer.Option(
False,
"--dry-run",
help="Report what would be repaired without creating or dropping any index.",
),
):
"""Verify and repair a bank's per-(bank, fact_type) vector index coverage.
Per-bank partial vector indexes are created when a bank is first created
(instant on an empty bank). Banks that arrive populated — via logical
restore, a cross-version upgrade, or a vector-extension switch — never hit
that path, so their recall silently falls back to a global index +
post-filter (slower, under-returning). This command detects missing OR
invalid coverage (an INVALID leftover or an index whose access method
drifted counts as missing) and rebuilds it with CREATE INDEX CONCURRENTLY,
so it never blocks the live fleet. Idempotent and safe to re-run — the
escape hatch after a restore, upgrade, or backend switch.
"""
if bool(bank_id) == all_banks:
typer.echo("Error: pass exactly one of --bank <id> or --all.", err=True)
raise typer.Exit(2)
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)
# Backend guard: backends with a single global vector index (AlloyDB ScaNN,
# Oracle) have no per-bank indexes to repair.
if _vector_index_clause() is None:
typer.echo("Configured vector backend does not use per-bank vector indexes — nothing to repair.")
return
target = f"bank '{bank_id}'" if bank_id else "all banks"
scope = f"schema '{schema}'" if schema else "base schema and all discovered tenant schemas"
typer.echo(f"Repairing per-bank vector indexes for {target} across {scope}...")
if dry_run:
typer.echo("Dry run: no indexes will be created or dropped.")
results = asyncio.run(
_run_repair_bank(
config.database_url,
base_schema=config.database_schema,
schema=schema,
bank_id=bank_id,
dry_run=dry_run,
)
)
total_banks = sum(r.banks_scanned for r in results)
total_present = sum(r.already_present for r in results)
total_created = sum(r.created for r in results)
total_skipped = sum(r.skipped for r in results)
total_failed = sum(r.failed for r in results)
typer.echo(
f"Done: {len(results)} schema(s), {total_banks} bank(s) scanned, "
f"{total_present} already present, {total_created} created, "
f"{total_skipped} to-create (dry-run), {total_failed} failed"
)
if total_failed:
failed_names = [name for r in results for name in r.failed_indexes]
typer.echo(f"Failed indexes (dropped, retry with a re-run): {', '.join(failed_names)}", err=True)
raise typer.Exit(1)
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)
@@ -360,7 +646,14 @@ async def _run_export_bank(db_url: str, bank_id: str, output: Path, schema: str,
# 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)
# _admin_connect registers JSON codecs, so row dumps already contain
# decoded Python values (including JSON scalar strings).
data = await export_bank(
conn,
bank_id,
include_history=include_history,
bank_rows_json_encoding="decoded",
)
finally:
await conn.close()
@@ -472,7 +765,8 @@ def import_bank_command(
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)
_pg0 = parse_pg0_url(db_url)
is_pg0, instance_name = _pg0.is_pg0, _pg0.instance_name
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
resolved_url = await resolve_database_url(db_url)
@@ -531,7 +825,8 @@ def decommission_worker(
async def _decommission_all_workers(db_url: str, schema: str = "public") -> list[dict[str, Any]]:
"""Release all processing tasks from all workers, setting them back to pending status."""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
_pg0 = parse_pg0_url(db_url)
is_pg0, instance_name = _pg0.is_pg0, _pg0.instance_name
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
resolved_url = await resolve_database_url(db_url)
@@ -596,7 +891,8 @@ def decommission_workers(
async def _worker_status(db_url: str, schema: str = "public") -> list[dict[str, Any]]:
"""Get all processing tasks grouped by worker with their last update time."""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
_pg0 = parse_pg0_url(db_url)
is_pg0, instance_name = _pg0.is_pg0, _pg0.instance_name
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
resolved_url = await resolve_database_url(db_url)
@@ -664,6 +960,7 @@ def worker_status(
def main():
load_dotenv_for_entrypoint()
app()
@@ -96,7 +96,8 @@ def get_database_url() -> str:
# for the sync engine used during migrations.
database_url = to_libpq_url(database_url)
config.set_main_option("sqlalchemy.url", database_url)
# Alembic stores options through ConfigParser, where '%' is interpolation.
config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
return database_url
@@ -0,0 +1,82 @@
"""Add indexes for terminal cleanup and newest-first operation listing.
Revision ID: a8c1e4f7b0d3
Revises: e7c3a9f1b2d5
Create Date: 2026-07-14
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a8c1e4f7b0d3"
down_revision: str | Sequence[str] | None = "e7c3a9f1b2d5"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for PostgreSQL multi-tenant migration runs."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# These can be large tables in long-running installations. Concurrent DDL
# keeps operation submission, polling, and status reads available.
with op.get_context().autocommit_block():
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_async_operations_terminal_cleanup "
f"ON {schema}async_operations (updated_at, operation_id) "
"WHERE status IN ('completed', 'failed', 'cancelled')"
)
op.execute(
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_async_operations_bank_created_desc "
f"ON {schema}async_operations (bank_id, created_at DESC)"
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
with op.get_context().autocommit_block():
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_async_operations_bank_created_desc")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_async_operations_terminal_cleanup")
def _oracle_create_index(sql: str) -> None:
"""Create an index idempotently for rerun-safe Oracle migrations."""
block = (
"BEGIN "
"EXECUTE IMMEDIATE :stmt; "
"EXCEPTION WHEN OTHERS THEN "
"IF SQLCODE = -955 THEN NULL; ELSE RAISE; END IF; "
"END;"
)
op.get_bind().exec_driver_sql(block, {"stmt": sql})
def _oracle_upgrade() -> None:
# Oracle migrations run with CURRENT_SCHEMA set to each tenant, so table
# and index names intentionally remain unqualified here.
_oracle_create_index(
"CREATE INDEX idx_async_operations_terminal_cleanup ON async_operations (updated_at, operation_id, status)"
)
_oracle_create_index(
"CREATE INDEX idx_async_operations_bank_created_desc ON async_operations (bank_id, created_at DESC)"
)
def _oracle_downgrade() -> None:
op.execute("DROP INDEX idx_async_operations_bank_created_desc")
op.execute("DROP INDEX idx_async_operations_terminal_cleanup")
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,259 @@
"""Install the maintenance discovery routines into the configured schema.
The three discovery routines driving the background maintenance loop —
``banks_needing_consolidation()``, ``schemas_with_expired_rows(...)`` and
``mental_models_with_cron()`` — were installed into ``public`` and gated on the
run being the base run (no ``target_schema``) or an explicit
``target_schema='public'`` run (``e5f6a7b8c9d0`` → ``b2d4f6a8c1e3`` →
``c7e9f1a3b5d2``, ``f4d1c2b3a5e6``).
That leaves a **single-tenant deployment migrated into a dedicated, non-**
``public`` **schema** (``HINDSIGHT_API_DATABASE_SCHEMA=<non-public>``) with no
routines at all: the runtime migrates only that one schema, so ``target_schema``
is never falsy or ``public``, the gate never opens, and the maintenance loop
logs, forever::
function public.banks_needing_consolidation() does not exist
function public.schemas_with_expired_rows(...) does not exist
The revision is stamped applied, so redeploying the same version does not help
(issue #2638; #2056 only fixed the ``public``/base-run case).
**The bug was the hardcoded literal, not the gating.** These routines are
database-global — each enumerates ``pg_class`` across every schema and dispatches
per schema — so exactly one copy should exist, and the maintenance loop calls the
one in ``get_config().database_schema`` (see ``fq_routine``). The old gate
installed into whichever schema was named ``public`` instead of whichever schema
the deployment is actually configured to use. Comparing ``target_schema`` against
the configured schema instead of the literal fixes #2638 at the source.
That also keeps the property the gate existed for: exactly one migration run
satisfies the predicate, so concurrent per-schema runs never issue competing
``CREATE OR REPLACE`` against the same ``pg_proc`` row and cannot hit
``tuple concurrently updated``. No cross-process coordination is required — in
particular no advisory lock, which is unusable here because Hindsight runs behind
connection poolers and managed PG services (see #2817).
Runs targeting any *other* schema drop the routines from that schema rather than
merely skipping. An earlier revision of this migration installed a copy into
every schema it touched, which left one dead duplicate per tenant on any database
that ran it; the drop makes the next migration pass clean those up instead of
leaving them behind forever.
PostgreSQL only: the maintenance loop and worker poller are PG-only, so the
Oracle slot is intentionally absent (mirrors ``e5f6a7b8c9d0``).
Revision ID: b6d2f8a4c1e7
Revises: a8c1e4f7b0d3
Create Date: 2026-07-20
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
from hindsight_api.config import get_config
revision: str = "b6d2f8a4c1e7"
down_revision: str | Sequence[str] | None = "a8c1e4f7b0d3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _configured_schema() -> str:
"""The one schema this deployment's routines live in and are called from."""
return get_config().database_schema or "public"
def _target_schema() -> str | None:
return context.config.get_main_option("target_schema")
def _is_install_run() -> bool:
"""True for the single run that owns the routines.
The base run (no ``target_schema``) and the run targeting the configured
schema are the same deployment-level run; every other target is a tenant
schema that must not carry its own copy.
"""
target = _target_schema()
return not target or target == _configured_schema()
def _prefix(schema: str | None) -> str:
"""Qualifier for ``schema``, or ``""`` to fall back to ``search_path``."""
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
if not _is_install_run():
_drop_stray_copies()
return
schema = _prefix(_target_schema())
op.execute(
f"""
CREATE OR REPLACE FUNCTION {schema}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
BEGIN
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);
EXCEPTION
-- Schema or its tables vanished between the pg_class
-- snapshot and this query (tenant dropped or migrating).
WHEN undefined_table OR invalid_schema_name OR undefined_column THEN
CONTINUE;
END;
END LOOP;
END;
$fn$;
"""
)
op.execute(
f"""
CREATE OR REPLACE FUNCTION {schema}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
BEGIN
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;
EXCEPTION
-- Schema or its table vanished mid-scan; skip it.
WHEN undefined_table OR invalid_schema_name OR undefined_column THEN
CONTINUE;
END;
IF has_expired THEN
RETURN NEXT sch;
END IF;
END LOOP;
END;
$fn$;
"""
)
op.execute(
f"""
CREATE OR REPLACE FUNCTION {schema}mental_models_with_cron()
RETURNS TABLE(schema_name text, bank_id text, mental_model_id text,
refresh_cron text, last_refreshed_at timestamptz)
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 = 'mental_models' AND c.relkind = 'r'
LOOP
BEGIN
RETURN QUERY EXECUTE format($q$
SELECT %1$L::text, mm.bank_id::text, mm.id::text,
mm.trigger->>'refresh_cron', mm.last_refreshed_at
FROM %1$I.mental_models mm
WHERE COALESCE(mm.trigger->>'refresh_cron', '') <> ''
AND NOT EXISTS (
SELECT 1 FROM %1$I.async_operations o
WHERE o.bank_id = mm.bank_id
AND o.operation_type = 'refresh_mental_model'
AND o.status IN ('pending', 'processing')
AND o.task_payload->>'mental_model_id' = mm.id::text
)
$q$, sch);
EXCEPTION
-- Schema or its tables vanished between the pg_class
-- snapshot and this query (tenant dropped or migrating).
WHEN undefined_table OR invalid_schema_name OR undefined_column THEN
CONTINUE;
END;
END LOOP;
END;
$fn$;
"""
)
def _drop_routines(schema: str | None) -> None:
prefix = _prefix(schema)
op.execute(f"DROP FUNCTION IF EXISTS {prefix}mental_models_with_cron()")
op.execute(f"DROP FUNCTION IF EXISTS {prefix}schemas_with_expired_rows(text, text, int)")
op.execute(f"DROP FUNCTION IF EXISTS {prefix}banks_needing_consolidation()")
def _drop_stray_copies() -> None:
"""Remove per-tenant duplicates left by the first cut of this migration.
That version installed a copy into every schema it touched, so a database
that ran it carries one dead duplicate per tenant — only the copy in the
configured schema is ever called. Dropping here means the next migration pass
cleans them up; without it they would persist for the life of the database.
Safe on a database that never had them: ``DROP FUNCTION IF EXISTS`` is a
no-op, and this branch never runs for the configured schema.
"""
_drop_routines(_target_schema())
def _pg_downgrade() -> None:
# Only drop what this migration uniquely owns. When the configured schema is
# ``public`` the copies there belong to e5f6a7b8c9d0 / f4d1c2b3a5e6, which are
# still applied at this point and drop them on their own downgrade — removing
# them here would strand those migrations without the functions they claim to
# have installed.
if not _is_install_run() or _configured_schema() == "public":
return
_drop_routines(_target_schema())
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,90 @@
"""Add ``causal_links`` to the curation archive (invalidated_memory_units).
Causal edges (``caused_by`` and the historical ``causes``/``enables``/
``prevents``) are retain-time extraction output: unlike temporal and semantic
links they cannot be recomputed from dates or embeddings, and graph maintenance
never rebuilds them. Invalidation MOVES a fact out of ``memory_units``, so the
``memory_links → memory_units`` FK cascade deletes every incident edge — and
revert had no way to bring the causal ones back (#2864).
This column parks the descriptors of the causal edges incident to an archived
fact — ``[{"from_unit_id", "to_unit_id", "link_type", "weight"}, ...]`` — so
revert can rematerialize them. It is deliberately unindexed and lives only on
the archive: live facts keep their causal edges in ``memory_links`` (curation
edits no longer delete them), and the archive is small, cold, and only read by
low-frequency curation operations.
Revision ID: c7d1e9a4b3f2
Revises: d7b2f8a1c934
Create Date: 2026-07-24
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c7d1e9a4b3f2"
down_revision: str | Sequence[str] | None = "d7b2f8a1c934"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_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 ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# NOT NULL DEFAULT is metadata-only on PG 11+, so this is cheap even on a
# large archive. Existing rows read as "no causal edges captured" — edges
# lost before this migration cannot be reconstructed and are not guessed.
op.execute(
f"ALTER TABLE {schema}invalidated_memory_units "
f"ADD COLUMN IF NOT EXISTS causal_links JSONB NOT NULL DEFAULT '[]'::jsonb"
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"ALTER TABLE {schema}invalidated_memory_units DROP COLUMN IF EXISTS causal_links")
def _oracle_upgrade() -> None:
# Kept in sync with PG for schema parity (curation itself is PostgreSQL-only
# today — it introspects pg_attribute to move rows between the two tables).
# Swallow ORA-01430 (column already exists) so the migration is idempotent.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units ADD (causal_links CLOB DEFAULT ''[]''
CONSTRAINT imu_causal_links_json CHECK (causal_links IS JSON))';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -1430 THEN RAISE; END IF;
END;
"""
)
def _oracle_downgrade() -> None:
# Swallow ORA-00904 (column does not exist).
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units DROP COLUMN causal_links';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -904 THEN RAISE; END IF;
END;
"""
)
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,150 @@
"""Add the ``schemas_with_expired_operations`` cross-tenant discovery routine.
The worker's terminal-operation cleanup (``a8c1e4f7b0d3``) opens a connection
and a prune transaction against *every* tenant schema on every cleanup cycle,
whether or not that tenant has anything to prune. At thousands of tenants that
is a per-cycle query storm whose cost is paid entirely by idle schemas.
This is the same problem ``public.schemas_with_expired_rows`` already solves for
the ``audit_log`` / ``llm_requests`` retention sweeps (``e5f6a7b8c9d0``): one
round-trip returns just the schemas that actually hold expired rows, and the
caller then does real work only there. ``async_operations`` needs its own
routine rather than reusing that one because eligibility is not "row older than
N days" — pending and processing rows are never prunable, so the status filter
has to be part of the predicate.
Install policy mirrors ``b6d2f8a4c1e7`` (#2638/#2824), the current behaviour for
the sibling routines: the routine is database-global — it enumerates ``pg_class``
across every schema and dispatches per schema — so exactly one copy should exist,
installed into the schema this deployment is *configured* to use and called from
there via ``fq_routine``. Gating on the literal ``"public"`` instead of the
configured schema is what left single-tenant deployments in a dedicated
non-``public`` schema without the routine (#2638).
Exactly one migration run satisfies that predicate, so concurrent per-schema runs
never issue competing ``CREATE OR REPLACE`` against the same ``pg_proc`` row and
cannot hit ``tuple concurrently updated``. No cross-process coordination is
required — in particular no advisory lock, which is unusable here because
Hindsight runs behind connection poolers and managed PG services (see #2817).
Each per-schema probe runs in its own ``BEGIN ... EXCEPTION`` block so a tenant
dropped mid-scan is skipped instead of aborting the sweep (see ``c7e9f1a3b5d2``).
Revision ID: d7b2f8a1c934
Revises: b6d2f8a4c1e7
Create Date: 2026-07-20
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
from hindsight_api.config import get_config
revision: str = "d7b2f8a1c934"
down_revision: str | Sequence[str] | None = "b6d2f8a4c1e7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _configured_schema() -> str:
"""The one schema this deployment's routines live in and are called from."""
return get_config().database_schema or "public"
def _target_schema() -> str | None:
return context.config.get_main_option("target_schema")
def _is_install_run() -> bool:
"""True for the single run that owns the routine (mirrors b6d2f8a4c1e7)."""
target = _target_schema()
return not target or target == _configured_schema()
def _prefix(schema: str | None) -> str:
"""Qualifier for ``schema``, or ``""`` to fall back to ``search_path``."""
return f'"{schema}".' if schema else ""
def _drop_routine(schema: str | None) -> None:
op.execute(f"DROP FUNCTION IF EXISTS {_prefix(schema)}schemas_with_expired_operations(int)")
def _pg_upgrade() -> None:
if not _is_install_run():
# Tenant schemas must not carry their own copy: the routine is
# database-global and only the configured schema's copy is ever called.
# Dropping (rather than skipping) also cleans up after any interim build
# of this branch that installed per-schema copies.
_drop_routine(_target_schema())
return
schema = _prefix(_target_schema())
op.execute(
f"""
CREATE OR REPLACE FUNCTION {schema}schemas_with_expired_operations(p_days int)
RETURNS SETOF text
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
has_expired boolean;
BEGIN
-- Zero (or negative) retention means "keep forever": report nothing
-- so the caller skips the sweep entirely.
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 = 'async_operations' AND c.relkind = 'r'
LOOP
BEGIN
-- Matches the worker's prune predicate: only terminal rows
-- are eligible, so a schema holding nothing but pending or
-- processing work is correctly reported as having nothing
-- to prune. Uses idx_async_operations_terminal_cleanup.
EXECUTE format(
'SELECT EXISTS ('
' SELECT 1 FROM %I.async_operations'
' WHERE status IN (''completed'', ''failed'', ''cancelled'')'
' AND updated_at < NOW() - make_interval(days => $1)'
')',
sch
) INTO has_expired USING p_days;
EXCEPTION
-- Schema or its table vanished between the pg_class
-- snapshot and this probe (tenant dropped or migrating).
WHEN undefined_table OR invalid_schema_name OR undefined_column THEN
CONTINUE;
END;
IF has_expired THEN
RETURN NEXT sch;
END IF;
END LOOP;
END;
$fn$;
"""
)
def _pg_downgrade() -> None:
# This migration is the sole creator of this routine — no older migration
# owns a copy the way e5f6a7b8c9d0 owns the public sibling routines — so the
# install run's own copy is always ours to drop.
if not _is_install_run():
return
_drop_routine(_target_schema())
def upgrade() -> None:
# Oracle slot intentionally absent: this mirrors the PostgreSQL-only
# maintenance routines, and the Oracle worker keeps its per-schema sweep.
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -0,0 +1,96 @@
"""Drop the search_vector column from the curation archive (invalidated_memory_units).
The archive is cold storage, never a recall surface, and carries no text-search
index. Like ``embedding`` (dropped in d4f6a8c2e1b3), ``search_vector`` is a
recall-surface column whose type follows the configured text-search backend, so
it has no business living on the archive. Earlier curation code copied the live
row's ``search_vector`` into ``invalidated_memory_units`` on invalidate; the
engine now leaves it out on invalidate and recomputes it on revert, so the
column is dead weight.
Dropping it removes a latent failure mode (#2503): under a non-native backend
(pgroonga / pg_textsearch / pg_search / vchord) ``ensure_text_search_extension``
reconciles ``memory_units.search_vector`` to ``text`` / ``bm25vector`` but never
touched the archive, which the ``LIKE memory_units`` clone (c9a1b2d3e4f5) created
as ``tsvector``. The type mismatch then broke the curation INSERT … SELECT
round-trip:
column "search_vector" is of type tsvector but expression is of type text
With no column at all, there is nothing to mismatch. Unlike ``embedding`` (whose
creation sites already omit it), the ``LIKE`` clone still adds ``search_vector``,
so this migration does real work on both fresh and existing PostgreSQL databases.
DROP COLUMN is a metadata-only operation on both PostgreSQL and Oracle 23ai (no
table rewrite), so it is cheap even across many tenant schemas. The downgrade
re-adds an empty ``tsvector`` column (its original creation type).
Revision ID: e7c3a9f1b2d5
Revises: b57a7c9e0d13
Create Date: 2026-07-02
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "e7c3a9f1b2d5"
down_revision: str | Sequence[str] | None = "b57a7c9e0d13"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_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 ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"ALTER TABLE {schema}invalidated_memory_units DROP COLUMN IF EXISTS search_vector")
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
# Re-add as the original tsvector creation type; comes back empty regardless.
op.execute(f"ALTER TABLE {schema}invalidated_memory_units ADD COLUMN IF NOT EXISTS search_vector tsvector")
def _oracle_upgrade() -> None:
# Oracle has no `DROP COLUMN IF EXISTS`; swallow ORA-00904 (column does not
# exist) so the migration is idempotent and safe on a schema whose baseline
# may already omit the column.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units DROP COLUMN search_vector';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -904 THEN RAISE; END IF;
END;
"""
)
def _oracle_downgrade() -> None:
# Swallow ORA-01430 (column already exists) for idempotency. Oracle stores
# search_vector as CLOB (see the Oracle baseline), so re-add it as CLOB.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units ADD (search_vector CLOB)';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -1430 THEN RAISE; END IF;
END;
"""
)
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
+411 -155
View File
@@ -27,7 +27,7 @@ from hindsight_api.engine.audit import (
AuditLogStatsResponse,
)
from hindsight_api.engine.llm_trace import LLMRequestListResponse, LLMRequestStatsResponse
from hindsight_api.extensions import AuthenticationError, PrecheckOperation
from hindsight_api.extensions import AuthenticationError, BankWriteOperation, PrecheckOperation
def _parse_metadata(metadata: Any) -> dict[str, Any]:
@@ -52,6 +52,7 @@ from fastapi.routing import APIRoute
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from hindsight_api import MemoryEngine
from hindsight_api.config import RETAIN_EXTRACTION_MODES
def _annotation_is_nullable(annotation: Any) -> bool:
@@ -148,8 +149,15 @@ def FieldWithDefault(default_factory: Callable, **kwargs) -> Any:
from hindsight_api.config import get_config
from hindsight_api.engine.memory_engine import Budget, _current_schema, _get_tiktoken_encoding
from hindsight_api.engine.interface import BankTemplateImportWrite
from hindsight_api.engine.memory_engine import (
Budget,
RetainOperationConflictError,
_current_schema,
_get_tiktoken_encoding,
)
from hindsight_api.engine.providers.none_llm import LLMNotAvailableError
from hindsight_api.engine.reflect import ReflectToolCallError
from hindsight_api.engine.response_models import (
VALID_RECALL_FACT_TYPES,
DryRunExtractionResult,
@@ -721,6 +729,25 @@ class RetainRequest(BaseModel):
description="Deprecated. Use item-level tags instead.",
deprecated=True,
)
operation_id: str | None = Field(
default=None,
description=(
"Optional client-supplied UUID used as the identity of an async retain operation. "
"Re-submitting with the same operation_id returns the original operation and creates no new "
"work, so retrying after a lost or timed-out acknowledgement will not enqueue a duplicate. "
"Reusing an id that belongs to a different operation returns HTTP 409. Ignored for synchronous retain."
),
)
@field_validator("operation_id")
@classmethod
def validate_operation_id(cls, value: str | None) -> str | None:
if value is None:
return None
try:
return str(uuid.UUID(value))
except (ValueError, AttributeError, TypeError) as exc:
raise ValueError("operation_id must be a valid UUID") from exc
class FileRetainMetadata(BaseModel):
@@ -769,6 +796,26 @@ class FileRetainRequest(BaseModel):
description="Metadata for each file (optional, must match number of files if provided)",
)
@model_validator(mode="before")
@classmethod
def reject_misplaced_file_metadata(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
misplaced = sorted(name for name in _FILE_RETAIN_PER_FILE_FIELDS if data.get(name) is not None)
errors = []
if misplaced:
fields = ", ".join(misplaced)
errors.append(f"Per-file fields ({fields}) must be placed in the corresponding 'files_metadata' entry")
if data.get("update_mode") is not None:
errors.append("'update_mode' is not supported by /files/retain, which always processes asynchronously")
if errors:
raise ValueError("; ".join(errors))
return data
_FILE_RETAIN_PER_FILE_FIELDS = frozenset(FileRetainMetadata.model_fields) - frozenset(FileRetainRequest.model_fields)
class RetainResponse(BaseModel):
"""Response model for retain endpoint."""
@@ -1245,7 +1292,7 @@ class CreateBankRequest(BaseModel):
)
retain_extraction_mode: str | None = Field(
default=None,
description="Fact extraction mode: 'concise' (default), 'verbose', or 'custom'.",
description="Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'.",
)
retain_custom_instructions: str | None = Field(
default=None,
@@ -1433,6 +1480,7 @@ class ListMemoryUnitsResponse(BaseModel):
"date": "2024-01-15T10:30:00Z",
"type": "world",
"entities": "Alice (PERSON), Google (ORGANIZATION)",
"metadata": {"source": "slack", "channel": "engineering"},
}
],
"total": 150,
@@ -1666,8 +1714,8 @@ class UpdateMemoryRequest(BaseModel):
@model_validator(mode="after")
def _require_an_edit(self) -> "UpdateMemoryRequest":
if all(
v is None
has_value_edit = any(
v is not None
for v in (
self.text,
self.context,
@@ -1677,7 +1725,9 @@ class UpdateMemoryRequest(BaseModel):
self.entities,
self.state,
)
):
)
has_date_clear = bool({"occurred_start", "occurred_end"} & self.model_fields_set)
if not has_value_edit and not has_date_clear:
raise ValueError("Provide at least one field to update.")
if self.state is not None and self.state not in ("valid", "invalidated"):
raise ValueError("state must be 'valid' or 'invalidated'.")
@@ -2203,7 +2253,8 @@ class BankTemplateConfig(BaseModel):
reflect_mission: str | None = Field(default=None, description="Mission/context for Reflect operations")
retain_mission: str | None = Field(default=None, description="Steers what gets extracted during retain")
retain_extraction_mode: str | None = Field(
default=None, description="Fact extraction mode: 'concise' (default), 'verbose', or 'custom'"
default=None,
description="Fact extraction mode: 'concise' (default), 'verbose', 'custom', 'verbatim', or 'chunks'",
)
retain_custom_instructions: str | None = Field(
default=None, description="Custom extraction prompt (when mode='custom')"
@@ -2292,6 +2343,14 @@ class BankTemplateConfig(BaseModel):
recall_budget_max: int | None = Field(
default=None, description="Ceiling for the adaptive function (after clamping)"
)
audit_log_enabled: bool | None = Field(
default=None, description="Enable audit logging for this bank (overrides the server default)"
)
store_document_text: bool | None = Field(
default=None,
description="Persist raw source text (documents.original_text / chunks.chunk_text). "
"Set false to keep only derived facts.",
)
def get_config_updates(self) -> dict[str, Any]:
"""Return only the fields that were explicitly set (non-None)."""
@@ -2429,58 +2488,230 @@ def validate_bank_template(manifest: "BankTemplateManifest") -> list[str]:
if manifest.bank:
bank = manifest.bank
if bank.retain_extraction_mode is not None:
valid_modes = ("concise", "verbose", "custom", "chunks")
if bank.retain_extraction_mode not in valid_modes:
if bank.retain_extraction_mode not in RETAIN_EXTRACTION_MODES:
errors.append(
f"bank.retain_extraction_mode: must be one of {valid_modes}, got '{bank.retain_extraction_mode}'"
"bank.retain_extraction_mode: "
f"must be one of {RETAIN_EXTRACTION_MODES}, got '{bank.retain_extraction_mode}'"
)
if bank.retain_custom_instructions and bank.retain_extraction_mode != "custom":
errors.append("bank.retain_custom_instructions: requires retain_extraction_mode='custom'")
if manifest.mental_models:
seen_mental_model_ids: set[str] = set()
for i, mm in enumerate(manifest.mental_models):
if not mm.name.strip():
errors.append(f"mental_models[{i}].name: must not be empty")
if not mm.source_query.strip():
errors.append(f"mental_models[{i}].source_query: must not be empty")
if mm.id in seen_mental_model_ids:
errors.append(f"mental_models[{i}].id: duplicate id '{mm.id}'")
seen_mental_model_ids.add(mm.id)
if manifest.directives:
seen_directive_names: set[str] = set()
for i, d in enumerate(manifest.directives):
if not d.name.strip():
errors.append(f"directives[{i}].name: must not be empty")
if not d.content.strip():
errors.append(f"directives[{i}].content: must not be empty")
if d.name in seen_directive_names:
errors.append(f"directives[{i}].name: duplicate name '{d.name}'")
seen_directive_names.add(d.name)
return errors
def load_default_bank_template_manifest() -> "BankTemplateManifest | None":
"""Parse and semantically validate the configured default bank template."""
template_dict = get_config().default_bank_template
if not template_dict:
return None
manifest = BankTemplateManifest.model_validate(template_dict)
semantic_errors = validate_bank_template(manifest)
if semantic_errors:
raise ValueError("; ".join(semantic_errors))
return manifest
async def apply_bank_template_manifest(
memory,
memory: MemoryEngine,
bank_id: str,
manifest: "BankTemplateManifest",
request_context: "RequestContext",
) -> "BankTemplateImportResponse":
"""Apply a validated BankTemplateManifest to an existing bank.
"""Apply a client-provided BankTemplateManifest to a bank.
Shared by the /import endpoint and the default-template-on-create hook
driven by HINDSIGHT_API_DEFAULT_BANK_TEMPLATE. The bank MUST already
exist; caller is responsible for validation (Pydantic + validate_bank_template).
The authorization context creates a missing bank after validating every
requested operation. The caller remains responsible for manifest validation
(Pydantic + validate_bank_template). Server-owned defaults use
``apply_default_bank_template_resources`` instead, so this function always
owns persistence of its client-provided config.
"""
config_applied = False
if manifest.bank:
config_updates = manifest.bank.get_config_updates()
if config_updates:
await memory._config_resolver.update_bank_config(bank_id, config_updates, request_context)
config_applied = True
config_updates = manifest.bank.get_config_updates() if manifest.bank else {}
bank_exists = (
await memory.get_bank_profile(
bank_id,
request_context=request_context,
create_if_missing=False,
)
is not None
)
# A missing bank receives the server-owned default template during
# provisioning. Project those resources into the authorization decision so
# the client's import is authorized as an update when the default owns the
# same key, while still keeping every client check before bank creation.
default_manifest: BankTemplateManifest | None = None
if not bank_exists:
try:
default_manifest = load_default_bank_template_manifest()
except (ValueError, ValidationError):
# Provisioning owns error logging and the best-effort fallback for a
# malformed server template. Client authorization must not change it.
pass
imported_mental_model_ids = {item.id for item in manifest.mental_models or []}
imported_directive_names = {item.name for item in manifest.directives or []}
default_mental_models = (default_manifest.mental_models or []) if default_manifest else []
default_directives = (default_manifest.directives or []) if default_manifest else []
projected_mental_model_ids = {item.id for item in default_mental_models} & imported_mental_model_ids
projected_directive_names = {item.name for item in default_directives} & imported_directive_names
existing_by_id: dict[str, dict[str, Any]] = {}
if bank_exists and manifest.mental_models:
existing = await memory.list_mental_models(bank_id=bank_id, request_context=request_context)
existing_by_id = {m["id"]: m for m in existing}
existing_by_name: dict[str, dict[str, Any]] = {}
if bank_exists and manifest.directives:
existing_directives = await memory.list_directives(
bank_id=bank_id, active_only=False, request_context=request_context
)
existing_by_name = {d["name"]: d for d in existing_directives}
bank_writes: list[BankTemplateImportWrite] = []
if config_updates:
bank_writes.append(BankTemplateImportWrite(BankWriteOperation.UPDATE_BANK_CONFIG))
for mental_model in manifest.mental_models or []:
if mental_model.id in existing_by_id:
bank_writes.append(BankTemplateImportWrite(BankWriteOperation.UPDATE_MENTAL_MODEL, mental_model.id))
elif mental_model.id in projected_mental_model_ids:
# Default-template application is best-effort. Authorize both
# outcomes before provisioning so a failed default create can
# safely fall back to the client's create operation.
bank_writes.extend(
[
BankTemplateImportWrite(BankWriteOperation.UPDATE_MENTAL_MODEL, mental_model.id),
BankTemplateImportWrite(BankWriteOperation.CREATE_MENTAL_MODEL, mental_model.id),
]
)
else:
bank_writes.append(BankTemplateImportWrite(BankWriteOperation.CREATE_MENTAL_MODEL, mental_model.id))
for directive in manifest.directives or []:
if directive.name in existing_by_name:
bank_writes.append(BankTemplateImportWrite(BankWriteOperation.UPDATE_DIRECTIVE, directive.name))
elif directive.name in projected_directive_names:
bank_writes.extend(
[
BankTemplateImportWrite(BankWriteOperation.UPDATE_DIRECTIVE, directive.name),
BankTemplateImportWrite(BankWriteOperation.CREATE_DIRECTIVE, directive.name),
]
)
else:
bank_writes.append(BankTemplateImportWrite(BankWriteOperation.CREATE_DIRECTIVE, directive.name))
async with memory.bank_template_import_authorization(
bank_id,
config_updates=config_updates,
bank_writes=bank_writes,
mental_model_ids=[mental_model.id for mental_model in manifest.mental_models or []],
bank_exists=bank_exists,
request_context=request_context,
):
if projected_mental_model_ids:
provisioned = await memory.list_mental_models(
bank_id=bank_id,
request_context=request_context,
)
provisioned_by_id = {item["id"]: item for item in provisioned}
existing_by_id.update(
{
item_id: provisioned_by_id[item_id]
for item_id in projected_mental_model_ids & provisioned_by_id.keys()
}
)
if projected_directive_names:
provisioned = await memory.list_directives(
bank_id=bank_id,
active_only=False,
request_context=request_context,
)
provisioned_by_name = {item["name"]: item for item in provisioned}
existing_by_name.update(
{name: provisioned_by_name[name] for name in projected_directive_names & provisioned_by_name.keys()}
)
if config_updates:
await memory.update_bank_config(bank_id, config_updates, request_context=request_context)
return await _apply_bank_template_resources(
memory,
bank_id,
manifest,
existing_by_id,
existing_by_name,
request_context,
config_applied=bool(config_updates),
)
async def apply_default_bank_template_resources(
memory: MemoryEngine,
bank_id: str,
manifest: "BankTemplateManifest",
request_context: "RequestContext",
) -> None:
"""Apply only the resources from a server-owned default template."""
existing_by_id: dict[str, dict[str, Any]] = {}
if manifest.mental_models:
existing = await memory.list_mental_models(bank_id=bank_id, request_context=request_context)
existing_by_id = {model["id"]: model for model in existing}
existing_by_name: dict[str, dict[str, Any]] = {}
if manifest.directives:
existing_directives = await memory.list_directives(
bank_id=bank_id,
active_only=False,
request_context=request_context,
)
existing_by_name = {directive["name"]: directive for directive in existing_directives}
await _apply_bank_template_resources(
memory,
bank_id,
manifest,
existing_by_id,
existing_by_name,
request_context,
config_applied=False,
)
async def _apply_bank_template_resources(
memory: MemoryEngine,
bank_id: str,
manifest: "BankTemplateManifest",
existing_mental_models: dict[str, dict[str, Any]],
existing_directives: dict[str, dict[str, Any]],
request_context: "RequestContext",
*,
config_applied: bool,
) -> "BankTemplateImportResponse":
"""Apply template resources after the caller has handled config and access."""
created_ids: list[str] = []
updated_ids: list[str] = []
operation_ids: list[str] = []
if manifest.mental_models:
# Fetch existing mental models to decide create vs update
existing = await memory.list_mental_models(bank_id=bank_id, request_context=request_context)
existing_by_id = {m["id"]: m for m in existing}
for mm in manifest.mental_models:
if mm.id in existing_by_id:
if mm.id in existing_mental_models:
await memory.update_mental_model(
bank_id=bank_id,
mental_model_id=mm.id,
@@ -2522,16 +2753,12 @@ async def apply_bank_template_manifest(
directives_updated: list[str] = []
if manifest.directives:
existing_directives = await memory.list_directives(
bank_id=bank_id, active_only=False, request_context=request_context
)
existing_by_name = {d["name"]: d for d in existing_directives}
for directive in manifest.directives:
if directive.name in existing_by_name:
if directive.name in existing_directives:
await memory.update_directive(
bank_id=bank_id,
directive_id=existing_by_name[directive.name]["id"],
directive_id=existing_directives[directive.name]["id"],
name=directive.name,
content=directive.content,
priority=directive.priority,
is_active=directive.is_active,
@@ -2725,6 +2952,24 @@ class RetryOperationResponse(BaseModel):
operation_id: str
class DeleteOperationResponse(BaseModel):
"""Response model for delete operation endpoint."""
model_config = ConfigDict(
json_schema_extra={
"example": {
"success": True,
"message": "Operation 550e8400-e29b-41d4-a716-446655440000 deleted",
"operation_id": "550e8400-e29b-41d4-a716-446655440000",
}
}
)
success: bool
message: str
operation_id: str
class ChildOperationStatus(BaseModel):
"""Status of a child operation (for batch operations)."""
@@ -2816,7 +3061,7 @@ class FeaturesInfo(BaseModel):
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")
audit_log: bool = Field(description="Whether audit logging is enabled by default (overridable per bank)")
llm_trace: bool = Field(description="Whether per-bank LLM request tracing is enabled")
store_document_text: bool = Field(
description="Whether raw source text is persisted. When false, document/chunk source text is not stored."
@@ -2986,10 +3231,15 @@ def _make_audited_http(audit_logger_getter: Callable[[], AuditLogger | None]):
@wraps(func)
async def wrapper(*args, **kwargs):
al = audit_logger_getter()
if al is None or not al.is_enabled(action):
# Cheap bank-independent pre-filter first, then the per-bank
# decision (audit_log_enabled is overridable per bank).
if al is None or not al.action_allowed(action):
return await func(*args, **kwargs)
bank_id = kwargs.get("bank_id")
if not await al.should_log(action, bank_id, kwargs.get("request_context")):
return await func(*args, **kwargs)
started_at = _dt.now(_tz.utc)
req_data = None
@@ -3074,6 +3324,7 @@ def create_app(
config = get_config()
poller = None
poller_task = None
loop_watchdog = None
# Initialize OpenTelemetry metrics
try:
@@ -3117,6 +3368,12 @@ def create_app(
metrics_collector.set_db_pool(memory._pool)
logging.info("DB pool metrics configured")
# Start the event-loop stall watchdog (logs the culprit stack if a task
# blocks the loop, so a failing /health can be told apart from pool exhaustion).
from ..loop_watchdog import start_loop_watchdog
loop_watchdog = start_loop_watchdog(asyncio.get_running_loop())
# Start worker poller if the backend supports it.
# All current backends (PostgreSQL, Oracle) support async worker/poller.
if config.worker_enabled and memory._backend.supports_worker_poller:
@@ -3161,6 +3418,10 @@ def create_app(
yield
# Stop the loop watchdog first so it doesn't fire during teardown.
if loop_watchdog is not None:
loop_watchdog.stop()
# Shutdown worker poller if running
if poller is not None:
await poller.shutdown_graceful(timeout=30.0)
@@ -3486,8 +3747,8 @@ def _register_routes(app: FastAPI):
Returns version info and feature flags that can be used by clients
to determine which capabilities are available.
Note: observations flag shows the global default. Individual banks
may override this setting via bank-specific configuration.
Note: the observations and audit_log flags show the global default.
Individual banks may override these via bank-specific configuration.
"""
from hindsight_api import __version__
from hindsight_api.config import _get_raw_config
@@ -3572,7 +3833,7 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/memories/list",
response_model=ListMemoryUnitsResponse,
summary="List memory units",
description="List memory units with pagination and optional full-text search. Supports filtering by type. Results are sorted by most recent first (mentioned_at DESC, then created_at DESC).",
description="List memory units with pagination and optional full-text search. Supports filtering by type, source document, and linked entity ID. Results are sorted by most recent first (mentioned_at DESC, then created_at DESC).",
operation_id="list_memories",
tags=["Memory"],
)
@@ -3583,6 +3844,9 @@ def _register_routes(app: FastAPI):
consolidation_state: str | None = None,
state: str | None = None,
document_id: str | None = None,
entity_id: str | None = None,
tags: list[str] | None = Query(default=None),
tags_match: TagsMatch = Query(default="any"),
limit: int = Query(default=100, ge=0),
offset: int = Query(default=0, ge=0),
request_context: RequestContext = Depends(get_request_context),
@@ -3599,6 +3863,14 @@ def _register_routes(app: FastAPI):
q: Search query for full-text search (searches text and context)
consolidation_state: Filter by consolidation state for source memories
(world/experience). One of 'failed', 'pending', or 'done'.
document_id: Filter to a single source document.
entity_id: Filter to memory units linked to this entity ID (via stored
entity links, not text/semantic match). Combining with
state='invalidated' returns no results (the archive has no links).
tags: Optional list of tag names to filter by.
tags_match: How to combine tags: 'any' (OR, default) or 'all' (AND) both
also include untagged memories; 'any_strict'/'all_strict' exclude
untagged; 'exact' matches the tag set exactly.
limit: Maximum number of results (default: 100)
offset: Offset for pagination (default: 0)
"""
@@ -3610,6 +3882,9 @@ def _register_routes(app: FastAPI):
consolidation_state=consolidation_state,
state=state,
document_id=document_id,
entity_id=entity_id,
tags=tags,
tags_match=tags_match,
limit=limit,
offset=offset,
request_context=request_context,
@@ -3742,6 +4017,7 @@ def _register_routes(app: FastAPI):
operation_id="update_memory",
tags=["Memory"],
)
@audited("update_memory")
async def api_update_memory(
bank_id: str,
memory_id: str,
@@ -3750,13 +4026,23 @@ def _register_routes(app: FastAPI):
):
"""Curate a single memory unit (edit text / invalidate / revert)."""
try:
occurred_start = (
""
if "occurred_start" in request.model_fields_set and request.occurred_start is None
else request.occurred_start
)
occurred_end = (
""
if "occurred_end" in request.model_fields_set and request.occurred_end is None
else request.occurred_end
)
data = await app.state.memory.update_memory_unit(
bank_id=bank_id,
memory_id=memory_id,
text=request.text,
context=request.context,
occurred_start=request.occurred_start,
occurred_end=request.occurred_end,
occurred_start=occurred_start,
occurred_end=occurred_end,
new_fact_type=request.fact_type,
entities=request.entities,
state=request.state,
@@ -4146,6 +4432,12 @@ def _register_routes(app: FastAPI):
raise
except LLMNotAvailableError as e:
raise HTTPException(status_code=400, detail=str(e))
except ReflectToolCallError as e:
# The configured model/transport can't drive reflect's tool-calling loop.
# The request itself is fine, so this is a server-side (500) failure, not a
# 4xx -- but log at warning, not error: it's a misconfiguration, not a bug.
logger.warning("Reflect tool-calling failure in bank %s: %s", bank_id, e)
raise HTTPException(status_code=500, detail=str(e))
except TimeoutError as e:
logger.error("Timeout in /v1/default/banks/%s/reflect: %s", bank_id, e)
raise HTTPException(
@@ -5407,8 +5699,9 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/operations/{operation_id}",
response_model=OperationStatusResponse,
summary="Get operation status",
description="Get the status of a specific async operation. Returns 'pending', 'completed', or 'failed'. "
"Completed operations are removed from storage, so 'completed' means the operation finished successfully.",
description="Get the status of a specific async operation. Returns 'pending', 'processing', 'completed', "
"'failed', or 'cancelled'. Completed operations remain queryable with their payload for the configured "
"retention window and are pruned afterward.",
operation_id="get_operation_status",
tags=["Operations"],
)
@@ -5513,6 +5806,42 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in POST /v1/default/banks/{bank_id}/operations/{operation_id}/retry: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.delete(
"/v1/default/banks/{bank_id}/operations/{operation_id}/delete",
response_model=DeleteOperationResponse,
summary="Delete a terminal async operation",
description="Permanently remove a failed, cancelled, or completed async operation record",
operation_id="delete_operation",
tags=["Operations"],
)
@audited("delete_operation", request_param=None)
async def api_delete_operation(
bank_id: str, operation_id: str, request_context: RequestContext = Depends(get_request_context)
):
"""Delete a terminal async operation record."""
try:
try:
uuid.UUID(operation_id)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid operation_id format: {operation_id}")
result = await app.state.memory.delete_operation(bank_id, operation_id, request_context=request_context)
return DeleteOperationResponse(**result)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(
f"Error in DELETE /v1/default/banks/{bank_id}/operations/{operation_id}/delete: {error_detail}"
)
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/profile",
response_model=BankProfileResponse,
@@ -5648,24 +5977,15 @@ def _register_routes(app: FastAPI):
):
"""Create or update an agent with disposition and mission."""
try:
# Ensure bank exists by getting profile (auto-creates with defaults)
await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
# Update name if provided (stored in DB for display only, deprecated)
if request.name is not None:
await app.state.memory.update_bank(
bank_id,
name=request.name,
request_context=request_context,
)
# Apply all config overrides (includes reflect_mission, disposition, retain settings)
# The engine validates and authorizes all requested changes before
# creating a missing bank.
config_updates = request.get_config_updates()
if config_updates:
await app.state.memory._config_resolver.update_bank_config(bank_id, config_updates, request_context)
# Get final profile
final_profile = await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
final_profile = await app.state.memory.update_bank(
bank_id,
name=request.name,
config_updates=config_updates or None,
request_context=request_context,
)
disposition_dict = (
final_profile["disposition"].model_dump()
if hasattr(final_profile["disposition"], "model_dump")
@@ -5681,6 +6001,8 @@ def _register_routes(app: FastAPI):
)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except (AuthenticationError, HTTPException):
raise
except Exception as e:
@@ -5704,33 +6026,16 @@ def _register_routes(app: FastAPI):
):
"""Partially update an agent's profile (name, mission, disposition)."""
try:
# PATCH is update-only; missing banks must not be created as a
# side effect of reading the profile.
existing_profile = await app.state.memory.get_bank_profile(
bank_id, request_context=request_context, create_if_missing=False
)
if existing_profile is None:
raise HTTPException(status_code=404, detail=f"Bank '{bank_id}' not found")
# Update name if provided (stored in DB for display only, deprecated)
if request.name is not None:
await app.state.memory.update_bank(
bank_id,
name=request.name,
request_context=request_context,
)
# Apply all config overrides (includes reflect_mission, disposition, retain settings)
# Update every requested field through one engine call so all
# authorization and validation completes before either write.
config_updates = request.get_config_updates()
if config_updates:
await app.state.memory._config_resolver.update_bank_config(bank_id, config_updates, request_context)
# Get final profile
final_profile = await app.state.memory.get_bank_profile(
bank_id, request_context=request_context, create_if_missing=False
final_profile = await app.state.memory.update_bank(
bank_id,
name=request.name,
config_updates=config_updates or None,
create_if_missing=False,
request_context=request_context,
)
if final_profile is None:
raise HTTPException(status_code=404, detail=f"Bank '{bank_id}' not found")
disposition_dict = (
final_profile["disposition"].model_dump()
if hasattr(final_profile["disposition"], "model_dump")
@@ -5746,6 +6051,8 @@ def _register_routes(app: FastAPI):
)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except (AuthenticationError, HTTPException):
raise
except Exception as e:
@@ -5841,9 +6148,6 @@ def _register_routes(app: FastAPI):
dry_run=True,
)
# Ensure bank exists (auto-creates with defaults if needed)
await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
return await apply_bank_template_manifest(
memory=app.state.memory,
bank_id=bank_id,
@@ -6218,25 +6522,8 @@ def _register_routes(app: FastAPI):
detail="Bank configuration API is disabled. Set HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true to re-enable.",
)
try:
# Authenticate and set schema context for multi-tenant DB queries
await app.state.memory._authenticate_tenant(request_context)
if app.state.memory._operation_validator:
from hindsight_api.extensions import BankReadContext, BankReadOperation
ctx = BankReadContext(
bank_id=bank_id, operation=BankReadOperation.GET_BANK_CONFIG, request_context=request_context
)
await app.state.memory._validate_operation(
app.state.memory._operation_validator.validate_bank_read(ctx)
)
# Get resolved config from config resolver
config_dict = await app.state.memory._config_resolver.get_bank_config(bank_id, request_context)
# Get bank-specific overrides only
bank_overrides = await app.state.memory._config_resolver._load_bank_config(bank_id)
return BankConfigResponse(bank_id=bank_id, config=config_dict, overrides=bank_overrides)
state = await app.state.memory.get_bank_config(bank_id, request_context=request_context)
return BankConfigResponse(bank_id=bank_id, config=state.config, overrides=state.overrides)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
@@ -6268,35 +6555,12 @@ def _register_routes(app: FastAPI):
detail="Bank configuration API is disabled. Set HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true to re-enable.",
)
try:
# Authenticate and set schema context for multi-tenant DB queries
await app.state.memory._authenticate_tenant(request_context)
if app.state.memory._operation_validator:
from hindsight_api.extensions import BankWriteContext, BankWriteOperation
ctx = BankWriteContext(
bank_id=bank_id, operation=BankWriteOperation.UPDATE_BANK_CONFIG, request_context=request_context
)
await app.state.memory._validate_operation(
app.state.memory._operation_validator.validate_bank_write(ctx)
)
# Validate Memory Defense policy shape before persisting.
if "memory_defense" in request.updates and request.updates["memory_defense"] is not None:
from hindsight_api.extensions.memory_defense import parse_policy
try:
parse_policy(request.updates["memory_defense"])
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"invalid memory_defense policy: {exc}")
# Update config via config resolver (validates configurable fields and permissions)
await app.state.memory._config_resolver.update_bank_config(bank_id, request.updates, request_context)
# Return updated config
config_dict = await app.state.memory._config_resolver.get_bank_config(bank_id, request_context)
bank_overrides = await app.state.memory._config_resolver._load_bank_config(bank_id)
return BankConfigResponse(bank_id=bank_id, config=config_dict, overrides=bank_overrides)
state = await app.state.memory.update_bank_config(
bank_id,
request.updates,
request_context=request_context,
)
return BankConfigResponse(bank_id=bank_id, config=state.config, overrides=state.overrides)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except ValueError as e:
@@ -6329,26 +6593,8 @@ def _register_routes(app: FastAPI):
detail="Bank configuration API is disabled. Set HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true to re-enable.",
)
try:
# Authenticate and set schema context for multi-tenant DB queries
await app.state.memory._authenticate_tenant(request_context)
if app.state.memory._operation_validator:
from hindsight_api.extensions import BankWriteContext, BankWriteOperation
ctx = BankWriteContext(
bank_id=bank_id, operation=BankWriteOperation.RESET_BANK_CONFIG, request_context=request_context
)
await app.state.memory._validate_operation(
app.state.memory._operation_validator.validate_bank_write(ctx)
)
# Reset config via config resolver
await app.state.memory._config_resolver.reset_bank_config(bank_id)
# Return updated config (should match defaults now)
config_dict = await app.state.memory._config_resolver.get_bank_config(bank_id, request_context)
bank_overrides = await app.state.memory._config_resolver._load_bank_config(bank_id)
return BankConfigResponse(bank_id=bank_id, config=config_dict, overrides=bank_overrides)
state = await app.state.memory.reset_bank_config(bank_id, request_context=request_context)
return BankConfigResponse(bank_id=bank_id, config=state.config, overrides=state.overrides)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
@@ -6741,6 +6987,11 @@ def _register_routes(app: FastAPI):
strategy_groups[effective].append(content_dict)
if request.async_:
if request.operation_id is not None and len(strategy_groups) != 1:
raise HTTPException(
status_code=400,
detail="operation_id requires all retain items to resolve to a single strategy",
)
# Async processing: one submit per strategy group
all_operation_ids = []
total_items_count = 0
@@ -6751,6 +7002,7 @@ def _register_routes(app: FastAPI):
document_tags=request.document_tags,
strategy=group_strategy,
request_context=request_context,
operation_id=request.operation_id,
)
all_operation_ids.append(result["operation_id"])
total_items_count += result["items_count"]
@@ -6817,6 +7069,10 @@ def _register_routes(app: FastAPI):
)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except RetainOperationConflictError as e:
# Caller reused an async retain operation_id that already belongs to
# a different operation.
raise HTTPException(status_code=409, detail=str(e))
except (AuthenticationError, HTTPException):
raise
except ValueError as e:
+331 -9
View File
@@ -19,12 +19,28 @@ from ._pg_search import normalize_pg_search_tokenizer
from ._vector_index import validate_extension
from .utils import mask_network_location
# Load .env file, searching current and parent directories (overrides existing env vars)
load_dotenv(find_dotenv(usecwd=True), override=True)
logger = logging.getLogger(__name__)
def load_dotenv_for_entrypoint() -> None:
"""Load a discovered ``.env`` file for Hindsight's own entry points.
Importing ``hindsight_api`` (or anything that pulls it in) must NOT mutate
the host application's ``os.environ``. See issue #2961: doing so at module
scope let an upward ``.env`` walk from the process cwd silently overwrite an
embedding application's own configuration.
This helper is therefore called explicitly from Hindsight's standalone entry
points only — the API server (CLI and ``hindsight_api.server:app``), the
worker, and the admin CLI. ``override=True`` is deliberate: it preserves the
exact precedence those entry points have always had (a discovered ``.env``
is authoritative over the ambient process environment). Because a library
import never reaches this code path, that precedence no longer leaks into
embedders.
"""
load_dotenv(find_dotenv(usecwd=True), override=True)
class ConfigFieldAccessError(AttributeError):
"""Raised when trying to access a bank-configurable field from global config."""
@@ -145,8 +161,18 @@ ENV_LLM_BEDROCK_SERVICE_TIER = "HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER"
ENV_LLM_GEMINI_SERVICE_TIER = "HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER"
ENV_LLM_EXTRA_BODY = "HINDSIGHT_API_LLM_EXTRA_BODY"
ENV_LLM_DEFAULT_HEADERS = "HINDSIGHT_API_LLM_DEFAULT_HEADERS"
# Grammar-enforced structured output. The global flag applies to every internal
# LLM call; the per-operation variants override it for a single operation, so an
# operator can enable strict schema where it fixes malformed/truncated JSON
# without paying the retry cost on operations whose model can't satisfy it.
# Resolution per operation: per-operation env -> global env -> built-in default.
ENV_LLM_STRICT_SCHEMA = "HINDSIGHT_API_LLM_STRICT_SCHEMA"
ENV_LLM_STRICT_SCHEMA_RETAIN = "HINDSIGHT_API_LLM_STRICT_SCHEMA_RETAIN"
ENV_LLM_STRICT_SCHEMA_REFLECT = "HINDSIGHT_API_LLM_STRICT_SCHEMA_REFLECT"
ENV_LLM_STRICT_SCHEMA_CONSOLIDATION = "HINDSIGHT_API_LLM_STRICT_SCHEMA_CONSOLIDATION"
ENV_LLM_SUPPORTS_MAX_ITEMS = "HINDSIGHT_API_LLM_SUPPORTS_MAX_ITEMS"
ENV_LLM_SEND_BANK_AS_USER = "HINDSIGHT_API_LLM_SEND_BANK_AS_USER"
ENV_LLM_OLLAMA_NUM_CTX = "HINDSIGHT_API_LLM_OLLAMA_NUM_CTX"
# Per-operation sampling temperature. Each internal LLM call uses a temperature
# tuned for its task (deterministic extraction vs. creative reflection). These
@@ -248,6 +274,35 @@ def _resolve_operation_temperature(operation_env: str, default: float) -> float
return _parse_temperature(raw)
def _resolve_operation_strict_schema(operation_env: str) -> bool:
"""Resolve a per-operation strict-schema flag: per-op env -> global env -> default.
Resolved to a concrete bool here rather than left as None, so the call site
passes an explicit value and a per-operation "false" can override a global
"true" (the wrapper honours an explicit False -- see LLMConfig.call).
"""
raw = os.getenv(operation_env)
if raw is None:
raw = os.getenv(ENV_LLM_STRICT_SCHEMA)
if raw is None:
return DEFAULT_LLM_STRICT_SCHEMA
return raw.strip().lower() in ("true", "1")
def _parse_boolean_env(env_name: str, default: bool) -> bool:
"""Parse a boolean environment variable, rejecting ambiguous values."""
raw = os.getenv(env_name)
if raw is None:
return default
normalized = raw.strip().lower()
if normalized in ("true", "1"):
return True
if normalized in ("false", "0"):
return False
raise ValueError(f"Invalid {env_name} value {raw!r}: expected true, false, 1, or 0")
# Per-operation LLM configuration (optional, falls back to global LLM config)
ENV_RETAIN_LLM_PROVIDER = "HINDSIGHT_API_RETAIN_LLM_PROVIDER"
ENV_RETAIN_LLM_API_KEY = "HINDSIGHT_API_RETAIN_LLM_API_KEY"
@@ -296,6 +351,7 @@ ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_CONSOLIDATION_LLM_LI
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_ALLOW_MPS = "HINDSIGHT_API_EMBEDDINGS_LOCAL_ALLOW_MPS"
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"
@@ -373,6 +429,7 @@ ENV_EMBEDDINGS_LITELLM_SDK_MODEL = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MODEL"
ENV_EMBEDDINGS_LITELLM_SDK_API_BASE = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_BASE"
ENV_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS"
ENV_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT"
ENV_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS"
ENV_RERANKER_LITELLM_SDK_API_KEY = "HINDSIGHT_API_RERANKER_LITELLM_SDK_API_KEY"
ENV_RERANKER_LITELLM_SDK_MODEL = "HINDSIGHT_API_RERANKER_LITELLM_SDK_MODEL"
ENV_RERANKER_LITELLM_SDK_API_BASE = "HINDSIGHT_API_RERANKER_LITELLM_SDK_API_BASE"
@@ -382,8 +439,10 @@ ENV_LITELLM_API_BASE = "HINDSIGHT_API_LITELLM_API_BASE"
ENV_LITELLM_API_KEY = "HINDSIGHT_API_LITELLM_API_KEY"
ENV_RERANKER_PROVIDER = "HINDSIGHT_API_RERANKER_PROVIDER"
ENV_RERANKER_SEND_BANK_AS_HEADER = "HINDSIGHT_API_RERANKER_SEND_BANK_AS_HEADER"
ENV_RERANKER_LOCAL_MODEL = "HINDSIGHT_API_RERANKER_LOCAL_MODEL"
ENV_RERANKER_LOCAL_FORCE_CPU = "HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"
ENV_RERANKER_LOCAL_ALLOW_MPS = "HINDSIGHT_API_RERANKER_LOCAL_ALLOW_MPS"
ENV_RERANKER_LOCAL_MAX_CONCURRENT = "HINDSIGHT_API_RERANKER_LOCAL_MAX_CONCURRENT"
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE = "HINDSIGHT_API_RERANKER_LOCAL_TRUST_REMOTE_CODE"
ENV_RERANKER_LOCAL_FP16 = "HINDSIGHT_API_RERANKER_LOCAL_FP16"
@@ -403,6 +462,9 @@ 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_GRAPH_SEED_MIN_SIMILARITY = "HINDSIGHT_API_GRAPH_SEED_MIN_SIMILARITY"
ENV_TEMPORAL_SEMANTIC_MIN_SIMILARITY = "HINDSIGHT_API_TEMPORAL_SEMANTIC_MIN_SIMILARITY"
ENV_SEMANTIC_LINK_MIN_SIMILARITY = "HINDSIGHT_API_SEMANTIC_LINK_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"
@@ -467,6 +529,12 @@ ENV_OTEL_DEPLOYMENT_ENVIRONMENT = "HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT"
ENV_METRICS_INCLUDE_BANK_ID = "HINDSIGHT_API_METRICS_INCLUDE_BANK_ID"
ENV_METRICS_BACKLOG_ENABLED = "HINDSIGHT_API_METRICS_BACKLOG_ENABLED"
# Runtime-stall observability (loop watchdog + DB pool acquire instrumentation)
ENV_LOOP_WATCHDOG_ENABLED = "HINDSIGHT_API_LOOP_WATCHDOG_ENABLED"
ENV_LOOP_WATCHDOG_STALL_THRESHOLD_MS = "HINDSIGHT_API_LOOP_WATCHDOG_STALL_THRESHOLD_MS"
ENV_LOOP_WATCHDOG_POLL_INTERVAL_MS = "HINDSIGHT_API_LOOP_WATCHDOG_POLL_INTERVAL_MS"
ENV_DB_ACQUIRE_WARN_THRESHOLD_MS = "HINDSIGHT_API_DB_ACQUIRE_WARN_THRESHOLD_MS"
# Vertex AI configuration
ENV_LLM_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"
ENV_LLM_VERTEXAI_REGION = "HINDSIGHT_API_LLM_VERTEXAI_REGION"
@@ -485,6 +553,11 @@ ENV_LLM_GEMINI_SAFETY_SETTINGS = "HINDSIGHT_API_LLM_GEMINI_SAFETY_SETTINGS"
# 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"
# Opt-in diagnostic: when truthy, log the exact request behind any LLM 4xx (the
# serialized request config with message bodies stripped + length-capped per-message
# previews). Off by default; server-level only. See engine/providers/llm_debug.py.
ENV_LLM_DEBUG_DUMP_4XX = "HINDSIGHT_API_LLM_DEBUG_DUMP_4XX"
# Retain settings
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
@@ -584,6 +657,7 @@ ENV_DB_POOL_MAX_SIZE = "HINDSIGHT_API_DB_POOL_MAX_SIZE"
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"
ENV_DB_MAX_PARALLEL_WORKERS_PER_GATHER = "HINDSIGHT_API_DB_MAX_PARALLEL_WORKERS_PER_GATHER"
# Wall-clock cap on model/connection initialization at startup. If embeddings,
# cross-encoder, or LLM verification hang (e.g. an offline HuggingFace download
@@ -598,6 +672,8 @@ ENV_WORKER_MAX_RETRIES = "HINDSIGHT_API_WORKER_MAX_RETRIES"
ENV_WORKER_TASK_RETRY_BACKOFF_SECONDS = "HINDSIGHT_API_WORKER_TASK_RETRY_BACKOFF_SECONDS"
ENV_WORKER_HTTP_PORT = "HINDSIGHT_API_WORKER_HTTP_PORT"
ENV_WORKER_MAX_SLOTS = "HINDSIGHT_API_WORKER_MAX_SLOTS"
ENV_OPERATION_RETENTION_DAYS = "HINDSIGHT_API_OPERATION_RETENTION_DAYS"
ENV_OPERATION_CLEANUP_BATCH_SIZE = "HINDSIGHT_API_OPERATION_CLEANUP_BATCH_SIZE"
# Per-operation-type slot reservations. Each entry maps an operation_type
# (as stored in async_operations.operation_type) to its env var and default.
@@ -614,9 +690,11 @@ WORKER_SLOT_RESERVATION_TYPES: dict[str, tuple[str, int]] = {
}
ENV_WORKER_CONSOLIDATION_BANK_PRIORITY = "HINDSIGHT_API_WORKER_CONSOLIDATION_BANK_PRIORITY"
ENV_RETAIN_MAX_CONCURRENT = "HINDSIGHT_API_RETAIN_MAX_CONCURRENT"
ENV_RETAIN_WALL_TIMEOUT = "HINDSIGHT_API_RETAIN_WALL_TIMEOUT"
# Reflect agent settings
ENV_REFLECT_MAX_ITERATIONS = "HINDSIGHT_API_REFLECT_MAX_ITERATIONS"
ENV_REFLECT_PROMPT_CACHE_ENABLED = "HINDSIGHT_API_REFLECT_PROMPT_CACHE_ENABLED"
ENV_REFLECT_MAX_CONTEXT_TOKENS = "HINDSIGHT_API_REFLECT_MAX_CONTEXT_TOKENS"
ENV_REFLECT_WALL_TIMEOUT = "HINDSIGHT_API_REFLECT_WALL_TIMEOUT"
ENV_REFLECT_MISSION = "HINDSIGHT_API_REFLECT_MISSION"
@@ -638,6 +716,7 @@ 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_BM25_MAX_QUERY_TERMS = "HINDSIGHT_API_BM25_MAX_QUERY_TERMS"
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.
@@ -657,10 +736,16 @@ ENV_RECENCY_DECAY_LINEAR_WINDOW_DAYS = "HINDSIGHT_API_RECENCY_DECAY_LINEAR_WINDO
ENV_RECENCY_DECAY_HALFLIFE_DAYS = "HINDSIGHT_API_RECENCY_DECAY_HALFLIFE_DAYS"
# Audit log settings
# AUDIT_LOG_ENABLED is the deployment-wide default and is overridable per bank
# (and per tenant) through the bank config API, so auditing can be turned on for
# individual banks without enabling it everywhere.
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"
# Retain reliability settings
ENV_FAIL_ON_EXTRACTION_ERRORS = "HINDSIGHT_API_FAIL_ON_EXTRACTION_ERRORS"
# LLM request tracing settings
ENV_LLM_TRACE_ENABLED = "HINDSIGHT_API_LLM_TRACE_ENABLED"
ENV_LLM_TRACE_SCOPES = "HINDSIGHT_API_LLM_TRACE_SCOPES"
@@ -725,6 +810,7 @@ DEFAULT_LLAMACPP_EXTRA_ARGS = None # Space-separated extra CLI args for llama.c
# (prose preambles, markdown fences, invalid JSON) — wedging retain/consolidation
# on parse retries.
DEFAULT_LLM_STRICT_SCHEMA = False
DEFAULT_LLM_SUPPORTS_MAX_ITEMS = True
DEFAULT_LLM_MAX_CONCURRENT = 32
DEFAULT_LLM_MAX_RETRIES = 3 # Max retry attempts for LLM API calls
@@ -745,6 +831,9 @@ DEFAULT_LLM_GEMINI_SAFETY_SETTINGS = None # None = use Gemini default safety se
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
# Apple Silicon MPS is opt-in: it leaks memory under variable-length workloads
# (unbounded per-shape kernel/allocator cache). CUDA/XPU still auto-select.
DEFAULT_EMBEDDINGS_LOCAL_ALLOW_MPS = False
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"
@@ -761,8 +850,12 @@ DEFAULT_EMBEDDINGS_GEMINI_FORCE_IPV4 = False
DEFAULT_EMBEDDING_DIMENSION = 384
DEFAULT_RERANKER_PROVIDER = "local"
DEFAULT_RERANKER_SEND_BANK_AS_HEADER = False
DEFAULT_RERANKER_LOCAL_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
DEFAULT_RERANKER_LOCAL_FORCE_CPU = False # Force CPU mode for local reranker
# Apple Silicon MPS is opt-in: it leaks memory under variable-length workloads
# (unbounded per-shape kernel/allocator cache). CUDA/XPU still auto-select.
DEFAULT_RERANKER_LOCAL_ALLOW_MPS = False
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT = 4 # Limit concurrent CPU-bound reranking to prevent thrashing
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE = (
False # Security: disabled by default, required for some models like jina-reranker-v2
@@ -785,10 +878,16 @@ DEFAULT_RERANKER_LITELLM_SDK_TIMEOUT = 60.0
DEFAULT_RERANKER_GOOGLE_TIMEOUT = 60.0
DEFAULT_RERANKER_MAX_CANDIDATES = 300
DEFAULT_SEMANTIC_MIN_SIMILARITY = 0.3
DEFAULT_GRAPH_SEED_MIN_SIMILARITY = 0.3
DEFAULT_TEMPORAL_SEMANTIC_MIN_SIMILARITY = 0.1
DEFAULT_SEMANTIC_LINK_MIN_SIMILARITY = 0.7
# 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
# Native tsvector BM25 can optionally cap the OR tsquery built from normalized
# query tokens. 0 preserves the historical uncapped behavior.
DEFAULT_BM25_MAX_QUERY_TERMS = 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.
@@ -909,6 +1008,10 @@ DEFAULT_RERANKER_LITELLM_MAX_TOKENS_PER_DOC: int | None = None
# LiteLLM SDK defaults
DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL = "cohere/embed-english-v3.0"
DEFAULT_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT = "float"
# Opt-in per-text input truncation (tiktoken cl100k_base tokens). Off by default;
# set to the embedding model's real input limit (e.g. 8192 for Bedrock Titan V2)
# to keep oversized content from permanently failing the embed call. See #2501.
DEFAULT_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS: int | None = None
DEFAULT_RERANKER_LITELLM_SDK_MODEL = "cohere/rerank-english-v3.0"
DEFAULT_HOST = "0.0.0.0"
@@ -959,6 +1062,7 @@ 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_LLM_DEBUG_DUMP_4XX = False # Log the exact request behind any LLM 4xx (diagnostic, off by default)
DEFAULT_RETAIN_BATCH_POLL_INTERVAL_SECONDS = 60 # Batch API polling interval in seconds
# File storage defaults
@@ -1040,6 +1144,14 @@ 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)
# Optional cap on Postgres planner parallelism for this process's pool
# connections (SET max_parallel_workers_per_gather). None leaves the server
# default untouched. Setting 0 on background-worker processes keeps bulk
# maintenance queries (consolidation, graph upkeep) from fanning out across
# cores that latency-sensitive foreground traffic is sharing — parallel
# workers buy latency, which background work doesn't need, at the cost of
# concurrent CPU footprint, which multi-tenant primaries do care about.
DEFAULT_DB_MAX_PARALLEL_WORKERS_PER_GATHER: int | None = None
DEFAULT_MODEL_INIT_TIMEOUT = 300 # seconds (cap on startup model/connection init; covers first-time downloads)
# Worker configuration (distributed task processing)
@@ -1050,10 +1162,28 @@ DEFAULT_WORKER_MAX_RETRIES = 3 # Max retries before marking task failed
DEFAULT_WORKER_TASK_RETRY_BACKOFF_SECONDS = 60 # Seconds between retries on transient task failure
DEFAULT_WORKER_HTTP_PORT = 8889 # HTTP port for worker metrics/health
DEFAULT_WORKER_MAX_SLOTS = 10 # Total concurrent tasks per worker
# Terminal rows keep their payload and metadata for one coherent debug/retry TTL.
# Zero retention days disables automatic pruning entirely, and is the default:
# operation history is a user-visible audit trail, so bounding it is an opt-in
# policy decision rather than something an upgrade silently applies.
DEFAULT_OPERATION_RETENTION_DAYS = 0
DEFAULT_OPERATION_CLEANUP_BATCH_SIZE = 1000
DEFAULT_RETAIN_MAX_CONCURRENT = 4 # Max concurrent retain DB phases (HNSW reads + writes). Limits I/O contention.
# Wall-clock ceiling for one retain task in the worker (0 disables). This is a
# deadlock/wedge backstop, not a latency target: a retain that blocks forever on
# a lock, an LLM permit or a queue put would otherwise hold its worker slot until
# the process restarts, and 'processing' is neither retryable nor cancellable
# through the API. Set well above any healthy retain so it only ever fires on a
# genuine wedge — the per-attempt LLM timeout and the retry budget already bound
# the normal slow path.
DEFAULT_RETAIN_WALL_TIMEOUT = 3600 # seconds (1 hour)
# Reflect agent settings
DEFAULT_REFLECT_MAX_ITERATIONS = 10 # Max tool call iterations before forcing response
# Step-by-step context caching for the reflect tool loop (Gemini). On by default;
# requires the global prompt cache (HINDSIGHT_API_LLM_PROMPT_CACHE_ENABLED) to also
# be on. Set false to force reflect to run uncached even when prompt caching is on.
DEFAULT_REFLECT_PROMPT_CACHE_ENABLED = True
DEFAULT_REFLECT_MAX_CONTEXT_TOKENS = 100_000 # Max accumulated context tokens before forcing final prompt
DEFAULT_REFLECT_WALL_TIMEOUT = 300 # Wall-clock timeout in seconds for the entire reflect operation (5 minutes)
DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS = -1 # Token budget for source facts in search_observations (-1 = disabled)
@@ -1089,11 +1219,26 @@ DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT = "development"
DEFAULT_METRICS_INCLUDE_BANK_ID = False # Disabled by default to avoid high-cardinality OTel metric growth
DEFAULT_METRICS_BACKLOG_ENABLED = False # Disabled by default: runs periodic per-schema COUNT queries
# Runtime-stall observability defaults. Both are cheap and on by default: the
# watchdog is a single background thread pinging the loop; the DB-pool acquire
# timing is a monotonic() delta per acquire. They turn a failing liveness probe
# from "pod restarted, cause unknown" into a logged root cause (blocked loop vs
# pool exhaustion).
DEFAULT_LOOP_WATCHDOG_ENABLED = True
DEFAULT_LOOP_WATCHDOG_STALL_THRESHOLD_MS = 1000 # log a stall once the loop is unresponsive this long
DEFAULT_LOOP_WATCHDOG_POLL_INTERVAL_MS = 250 # how often the watchdog thread pings the loop
DEFAULT_DB_ACQUIRE_WARN_THRESHOLD_MS = 1000 # log a warning when a pool acquire waits this long
# Audit log defaults
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
# Retain reliability defaults
DEFAULT_FAIL_ON_EXTRACTION_ERRORS = (
False # Preserve existing behavior: retain completes even if some chunks fail extraction
)
# LLM request tracing defaults
DEFAULT_LLM_TRACE_ENABLED = True # Enabled by default
DEFAULT_LLM_TRACE_SCOPES = "" # Empty = trace all call scopes
@@ -1209,6 +1354,19 @@ def _parse_positive_int(name: str, raw: str | None, default: int) -> int:
return parsed
def _parse_non_negative_int(name: str, raw: str | None, default: int) -> int:
"""Parse an env var that must be an integer >= 0."""
if raw is None or raw == "":
return default
try:
parsed = int(raw)
except ValueError as e:
raise ValueError(f"{name} must be an integer, got {raw!r}") from e
if parsed < 0:
raise ValueError(f"{name} must be >= 0, got {parsed}")
return parsed
def _parse_optional_positive_int(name: str, raw: str | None) -> int | None:
"""Parse an optional env var that must be a positive integer when set."""
if raw is None or raw == "":
@@ -1216,6 +1374,25 @@ def _parse_optional_positive_int(name: str, raw: str | None) -> int | None:
return _parse_positive_int(name, raw, 1)
def _parse_optional_non_negative_int(name: str, raw: str | None) -> int | None:
"""
Parse an optional env var that must be a non-negative integer when set.
Unlike ``_parse_optional_positive_int``, 0 is a meaningful value here —
e.g. ``max_parallel_workers_per_gather = 0`` disables planner parallelism
entirely. Unset/empty means "no opinion" (None).
"""
if raw is None or raw == "":
return None
try:
parsed = int(raw)
except ValueError as e:
raise ValueError(f"{name} must be an integer, got {raw!r}") from e
if parsed < 0:
raise ValueError(f"{name} must be >= 0, got {parsed}")
return parsed
def _validate_retain_chunking_int(name: str, value: Any) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{name} must be an integer, got {value!r}")
@@ -1572,11 +1749,24 @@ class HindsightConfig:
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)
# Per-operation strict-schema overrides. Resolved from the per-operation env
# var, falling back to llm_strict_schema's global env var. See
# ENV_LLM_STRICT_SCHEMA and _resolve_operation_strict_schema.
llm_strict_schema_retain: bool
llm_strict_schema_reflect: bool
llm_strict_schema_consolidation: bool
llm_supports_max_items: bool = field(
default=DEFAULT_LLM_SUPPORTS_MAX_ITEMS,
kw_only=True,
) # Whether structured-output schemas accept JSON Schema maxItems
# Tags outbound OpenAI-compatible LLM + embedding calls with `user=<bank_id>` for
# per-bank cost attribution. Downstream cost gateways (OpenRouter usage accounting,
# LiteLLM, Helicone) key attribution on the OpenAI `user` field. Opt-in; never
# overrides a `user` the caller already set.
llm_send_bank_as_user: bool
# Optional native Ollama context window override. Unset lets Ollama use the
# model/server default instead of forcing a Hindsight-wide value.
llm_ollama_num_ctx: int | None = field(default=None, kw_only=True)
# Per-operation sampling temperature. None means the temperature parameter is
# omitted from the call (for models that reject explicit temperatures). See
@@ -1604,6 +1794,10 @@ class HindsightConfig:
# CachedContent prefix for its system prompt + response schema.
llm_prompt_cache_enabled: bool
# Opt-in diagnostic: log the exact request behind any LLM 4xx. Off by default;
# server-level only (not per-bank overridable). See engine/providers/llm_debug.py.
llm_debug_dump_4xx: 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
@@ -1655,6 +1849,7 @@ class HindsightConfig:
embeddings_provider: str
embeddings_local_model: str
embeddings_local_force_cpu: bool
embeddings_local_allow_mps: bool
embeddings_local_trust_remote_code: bool
embeddings_onnx_model_id: str
embeddings_onnx_model_path: str | None
@@ -1685,6 +1880,7 @@ class HindsightConfig:
embeddings_litellm_sdk_api_base: str | None
embeddings_litellm_sdk_output_dimensions: int | None
embeddings_litellm_sdk_encoding_format: str | None
embeddings_litellm_sdk_max_input_tokens: int | None
# Gemini/Vertex AI embeddings
embeddings_gemini_api_key: str | None
embeddings_gemini_model: str
@@ -1696,8 +1892,10 @@ class HindsightConfig:
# Reranker
reranker_provider: str
reranker_send_bank_as_header: bool
reranker_local_model: str
reranker_local_force_cpu: bool
reranker_local_allow_mps: bool
reranker_local_max_concurrent: int
reranker_local_trust_remote_code: bool
reranker_local_fp16: bool
@@ -1709,6 +1907,9 @@ class HindsightConfig:
reranker_tei_http_timeout: float
reranker_max_candidates: int
semantic_min_similarity: float
graph_seed_min_similarity: float
temporal_semantic_min_similarity: float
semantic_link_min_similarity: float
bm25_min_score: float
recall_max_candidates_per_source: int
recall_strategy_boosts: dict[str, str]
@@ -1896,6 +2097,7 @@ class HindsightConfig:
db_command_timeout: int
db_acquire_timeout: int
db_statement_timeout: int
db_max_parallel_workers_per_gather: int | None
model_init_timeout: float
# Worker configuration (distributed task processing)
@@ -1908,12 +2110,16 @@ class HindsightConfig:
worker_max_slots: int
worker_slot_reservations: dict[str, int]
worker_consolidation_bank_priority: dict[str, int]
operation_retention_days: int
operation_cleanup_batch_size: int
retain_max_concurrent: int
retain_wall_timeout: int
# Reflect agent settings
reflect_max_iterations: int
reflect_max_context_tokens: int
reflect_wall_timeout: int
reflect_prompt_cache_enabled: bool
# OpenTelemetry tracing configuration
otel_traces_enabled: bool
@@ -1924,11 +2130,25 @@ class HindsightConfig:
metrics_include_bank_id: bool
metrics_backlog_enabled: bool
# Audit log configuration (static - server-level only)
audit_log_enabled: bool # Master switch for audit logging
# Runtime-stall observability (static, server-level only)
loop_watchdog_enabled: bool
loop_watchdog_stall_threshold_ms: int
loop_watchdog_poll_interval_ms: int
db_acquire_warn_threshold_ms: int
# Audit log configuration
# audit_log_enabled is hierarchical (env -> tenant -> bank): a deployment can
# audit some banks and not others. The actions allowlist and retention window
# stay static (server-level): retention is a global sweep with no bank scope.
audit_log_enabled: bool # Whether audit logging is on (overridable per bank)
audit_log_actions: list[str] # Allowlist of action types (empty = all)
audit_log_retention_days: int # -1 = keep forever, >0 = delete after N days
# Retain reliability configuration (static - server-level only)
# When True, a retain operation that accumulated any fact-extraction errors is
# marked 'failed' instead of 'completed', surfacing silent fact loss to clients.
fail_on_extraction_errors: bool
# 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)
@@ -1979,6 +2199,7 @@ class HindsightConfig:
reflect_llm_strategy: LLMStrategyConfig | None = None
consolidation_llm_members: list[LLMMemberConfig] = field(default_factory=list)
consolidation_llm_strategy: LLMStrategyConfig | None = None
bm25_max_query_terms: int = DEFAULT_BM25_MAX_QUERY_TERMS
# Class-level sets for configuration categorization
@@ -2036,6 +2257,13 @@ class HindsightConfig:
_CONFIGURABLE_FIELDS = {
# MCP tool access control
"mcp_enabled_tools",
# Audit logging on/off, per bank. The actions allowlist and retention
# window remain server-level and are deliberately not configurable.
"audit_log_enabled",
# Persist raw source text (documents.original_text / chunks.chunk_text).
# Per-bank so a data-minimizing bank can keep only derived facts while
# others retain the raw source for expansion/re-extraction.
"store_document_text",
# Retention settings (behavioral)
"retain_chunk_size",
"retain_structured_chunk_size",
@@ -2174,10 +2402,18 @@ 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"
)
for field_name in (
"semantic_min_similarity",
"graph_seed_min_similarity",
"temporal_semantic_min_similarity",
"semantic_link_min_similarity",
):
value = getattr(self, field_name)
if not 0.0 <= value <= 1.0:
raise ValueError(f"Invalid {field_name}: {value}. Must be between 0.0 and 1.0")
if self.bm25_max_query_terms < 0:
raise ValueError(f"Invalid bm25_max_query_terms: {self.bm25_max_query_terms}. Must be >= 0")
# Validate bedrock_service_tier
valid_bedrock_tiers = (None, "flex", "priority", "reserved")
@@ -2268,6 +2504,13 @@ class HindsightConfig:
f"Reduce reservations or increase HINDSIGHT_API_WORKER_MAX_SLOTS."
)
if self.operation_retention_days < 0:
raise ValueError(f"{ENV_OPERATION_RETENTION_DAYS} must be >= 0, got {self.operation_retention_days}")
if self.operation_cleanup_batch_size < 1:
raise ValueError(
f"{ENV_OPERATION_CLEANUP_BATCH_SIZE} must be >= 1, got {self.operation_cleanup_batch_size}"
)
@classmethod
def from_env(cls) -> "HindsightConfig":
"""Create configuration from environment variables."""
@@ -2317,8 +2560,19 @@ class HindsightConfig:
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_strict_schema_retain=_resolve_operation_strict_schema(ENV_LLM_STRICT_SCHEMA_RETAIN),
llm_strict_schema_reflect=_resolve_operation_strict_schema(ENV_LLM_STRICT_SCHEMA_REFLECT),
llm_strict_schema_consolidation=_resolve_operation_strict_schema(ENV_LLM_STRICT_SCHEMA_CONSOLIDATION),
llm_supports_max_items=_parse_boolean_env(
ENV_LLM_SUPPORTS_MAX_ITEMS,
DEFAULT_LLM_SUPPORTS_MAX_ITEMS,
),
llm_send_bank_as_user=os.getenv(ENV_LLM_SEND_BANK_AS_USER, str(DEFAULT_LLM_SEND_BANK_AS_USER)).lower()
in ("true", "1"),
llm_ollama_num_ctx=_parse_optional_positive_int(
ENV_LLM_OLLAMA_NUM_CTX,
os.getenv(ENV_LLM_OLLAMA_NUM_CTX),
),
llm_temperature_verification=_resolve_operation_temperature(
ENV_LLM_TEMPERATURE_VERIFICATION, DEFAULT_LLM_TEMPERATURE_VERIFICATION
),
@@ -2343,6 +2597,8 @@ class HindsightConfig:
ENV_LLM_PROMPT_CACHE_ENABLED, str(DEFAULT_LLM_PROMPT_CACHE_ENABLED)
).lower()
in ("1", "true", "yes", "on"),
llm_debug_dump_4xx=os.getenv(ENV_LLM_DEBUG_DUMP_4XX, str(DEFAULT_LLM_DEBUG_DUMP_4XX)).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))),
@@ -2446,6 +2702,10 @@ class HindsightConfig:
ENV_EMBEDDINGS_LOCAL_FORCE_CPU, str(DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU)
).lower()
in ("true", "1"),
embeddings_local_allow_mps=os.getenv(
ENV_EMBEDDINGS_LOCAL_ALLOW_MPS, str(DEFAULT_EMBEDDINGS_LOCAL_ALLOW_MPS)
).lower()
in ("true", "1"),
embeddings_local_trust_remote_code=os.getenv(
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE, str(DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE)
).lower()
@@ -2555,6 +2815,9 @@ class HindsightConfig:
embeddings_litellm_sdk_encoding_format=os.getenv(
ENV_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT, DEFAULT_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT
),
embeddings_litellm_sdk_max_input_tokens=int(v)
if (v := os.getenv(ENV_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS))
else DEFAULT_EMBEDDINGS_LITELLM_SDK_MAX_INPUT_TOKENS,
# Gemini/Vertex AI embeddings (with fallback to LLM keys)
embeddings_gemini_api_key=os.getenv(ENV_EMBEDDINGS_GEMINI_API_KEY) or os.getenv(ENV_LLM_API_KEY),
embeddings_gemini_model=os.getenv(ENV_EMBEDDINGS_GEMINI_MODEL, DEFAULT_EMBEDDINGS_GEMINI_MODEL),
@@ -2576,11 +2839,20 @@ class HindsightConfig:
or os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY),
# Reranker
reranker_provider=os.getenv(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER),
reranker_send_bank_as_header=os.getenv(
ENV_RERANKER_SEND_BANK_AS_HEADER,
str(DEFAULT_RERANKER_SEND_BANK_AS_HEADER),
).lower()
in ("true", "1"),
reranker_local_model=os.getenv(ENV_RERANKER_LOCAL_MODEL, DEFAULT_RERANKER_LOCAL_MODEL),
reranker_local_force_cpu=os.getenv(
ENV_RERANKER_LOCAL_FORCE_CPU, str(DEFAULT_RERANKER_LOCAL_FORCE_CPU)
).lower()
in ("true", "1"),
reranker_local_allow_mps=os.getenv(
ENV_RERANKER_LOCAL_ALLOW_MPS, str(DEFAULT_RERANKER_LOCAL_ALLOW_MPS)
).lower()
in ("true", "1"),
reranker_local_max_concurrent=int(
os.getenv(ENV_RERANKER_LOCAL_MAX_CONCURRENT, str(DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT))
),
@@ -2607,7 +2879,21 @@ class HindsightConfig:
),
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))),
graph_seed_min_similarity=float(
os.getenv(ENV_GRAPH_SEED_MIN_SIMILARITY, str(DEFAULT_GRAPH_SEED_MIN_SIMILARITY))
),
temporal_semantic_min_similarity=float(
os.getenv(ENV_TEMPORAL_SEMANTIC_MIN_SIMILARITY, str(DEFAULT_TEMPORAL_SEMANTIC_MIN_SIMILARITY))
),
semantic_link_min_similarity=float(
os.getenv(ENV_SEMANTIC_LINK_MIN_SIMILARITY, str(DEFAULT_SEMANTIC_LINK_MIN_SIMILARITY))
),
bm25_min_score=float(os.getenv(ENV_BM25_MIN_SCORE, str(DEFAULT_BM25_MIN_SCORE))),
bm25_max_query_terms=_parse_non_negative_int(
ENV_BM25_MAX_QUERY_TERMS,
os.getenv(ENV_BM25_MAX_QUERY_TERMS),
DEFAULT_BM25_MAX_QUERY_TERMS,
),
recall_max_candidates_per_source=int(
os.getenv(ENV_RECALL_MAX_CANDIDATES_PER_SOURCE, str(DEFAULT_RECALL_MAX_CANDIDATES_PER_SOURCE))
),
@@ -2902,6 +3188,10 @@ 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))),
db_max_parallel_workers_per_gather=_parse_optional_non_negative_int(
ENV_DB_MAX_PARALLEL_WORKERS_PER_GATHER,
os.getenv(ENV_DB_MAX_PARALLEL_WORKERS_PER_GATHER),
),
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",
@@ -2924,9 +3214,24 @@ class HindsightConfig:
worker_consolidation_bank_priority=_parse_bank_priority(
os.getenv(ENV_WORKER_CONSOLIDATION_BANK_PRIORITY, "")
),
operation_retention_days=_parse_non_negative_int(
ENV_OPERATION_RETENTION_DAYS,
os.getenv(ENV_OPERATION_RETENTION_DAYS),
DEFAULT_OPERATION_RETENTION_DAYS,
),
operation_cleanup_batch_size=_parse_positive_int(
ENV_OPERATION_CLEANUP_BATCH_SIZE,
os.getenv(ENV_OPERATION_CLEANUP_BATCH_SIZE),
DEFAULT_OPERATION_CLEANUP_BATCH_SIZE,
),
retain_max_concurrent=int(os.getenv(ENV_RETAIN_MAX_CONCURRENT, str(DEFAULT_RETAIN_MAX_CONCURRENT))),
retain_wall_timeout=int(os.getenv(ENV_RETAIN_WALL_TIMEOUT, str(DEFAULT_RETAIN_WALL_TIMEOUT))),
# Reflect agent settings
reflect_max_iterations=int(os.getenv(ENV_REFLECT_MAX_ITERATIONS, str(DEFAULT_REFLECT_MAX_ITERATIONS))),
reflect_prompt_cache_enabled=os.getenv(
ENV_REFLECT_PROMPT_CACHE_ENABLED, str(DEFAULT_REFLECT_PROMPT_CACHE_ENABLED)
).lower()
in ("1", "true", "yes", "on"),
reflect_max_context_tokens=int(
os.getenv(ENV_REFLECT_MAX_CONTEXT_TOKENS, str(DEFAULT_REFLECT_MAX_CONTEXT_TOKENS))
),
@@ -2981,6 +3286,18 @@ class HindsightConfig:
in ("true", "1", "yes"),
metrics_backlog_enabled=os.getenv(ENV_METRICS_BACKLOG_ENABLED, str(DEFAULT_METRICS_BACKLOG_ENABLED)).lower()
in ("true", "1", "yes"),
# Runtime-stall observability (static, server-level only)
loop_watchdog_enabled=os.getenv(ENV_LOOP_WATCHDOG_ENABLED, str(DEFAULT_LOOP_WATCHDOG_ENABLED)).lower()
in ("true", "1", "yes"),
loop_watchdog_stall_threshold_ms=int(
os.getenv(ENV_LOOP_WATCHDOG_STALL_THRESHOLD_MS, str(DEFAULT_LOOP_WATCHDOG_STALL_THRESHOLD_MS))
),
loop_watchdog_poll_interval_ms=int(
os.getenv(ENV_LOOP_WATCHDOG_POLL_INTERVAL_MS, str(DEFAULT_LOOP_WATCHDOG_POLL_INTERVAL_MS))
),
db_acquire_warn_threshold_ms=int(
os.getenv(ENV_DB_ACQUIRE_WARN_THRESHOLD_MS, str(DEFAULT_DB_ACQUIRE_WARN_THRESHOLD_MS))
),
# Audit log configuration (static, server-level only)
audit_log_enabled=os.getenv(ENV_AUDIT_LOG_ENABLED, str(DEFAULT_AUDIT_LOG_ENABLED)).lower() == "true",
audit_log_actions=[
@@ -2989,6 +3306,11 @@ class HindsightConfig:
audit_log_retention_days=int(
os.getenv(ENV_AUDIT_LOG_RETENTION_DAYS, str(DEFAULT_AUDIT_LOG_RETENTION_DAYS))
),
# Retain reliability configuration (static, server-level only)
fail_on_extraction_errors=os.getenv(
ENV_FAIL_ON_EXTRACTION_ERRORS, str(DEFAULT_FAIL_ON_EXTRACTION_ERRORS)
).lower()
== "true",
# 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=[
@@ -331,11 +331,17 @@ class ConfigResolver:
logger.error(f"Failed to bulk-load bank configs: {e}")
return result
async def update_bank_config(
self, bank_id: str, updates: dict[str, Any], context: RequestContext | None = None
) -> None:
async def validate_bank_config_updates(
self,
bank_id: str,
updates: dict[str, Any],
context: RequestContext | None = None,
*,
projected_bank_overrides: dict[str, Any] | None = None,
check_permissions: bool = True,
) -> dict[str, Any]:
"""
Update bank configuration overrides (with permission checking).
Normalize and validate bank configuration overrides.
Args:
bank_id: Bank identifier
@@ -344,9 +350,16 @@ class ConfigResolver:
or Python field format (llm_provider).
Only configurable fields are allowed.
context: Request context for permission checking
projected_bank_overrides: Bank overrides to use as the validation
base instead of loading the current bank row.
check_permissions: Whether client field permissions apply to these
updates. Server-owned projected values set this to false.
Returns:
Normalized updates ready to persist.
Raises:
ValueError: If attempting to override invalid/disallowed fields
ValueError: If attempting to override invalid/disallowed fields.
"""
# Normalize keys
normalized_updates = normalize_config_dict(updates)
@@ -378,7 +391,7 @@ class ConfigResolver:
)
# PERMISSIONS: Check tenant/bank permissions
if self.tenant_extension and context:
if check_permissions and self.tenant_extension and context:
try:
allowed_fields = await self.tenant_extension.get_allowed_config_fields(context, bank_id)
if allowed_fields is not None: # None means "allow all"
@@ -427,7 +440,11 @@ class ConfigResolver:
)
if chunking_fields_updated:
config_dict = await self._resolve_parent_config_dict(bank_id, context)
active_bank_overrides = await self._load_bank_config(bank_id)
active_bank_overrides = (
await self._load_bank_config(bank_id)
if projected_bank_overrides is None
else dict(projected_bank_overrides)
)
for key, value in normalized_updates.items():
if key not in self._configurable_fields:
continue
@@ -443,17 +460,26 @@ class ConfigResolver:
)
_validate_retain_strategy_chunking(base_config, base_config.retain_strategies)
# 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
return normalized_updates
async def update_bank_config(
self, bank_id: str, updates: dict[str, Any], context: RequestContext | None = None
) -> None:
"""Validate and persist bank configuration overrides for an existing bank.
Bank creation belongs to ``MemoryEngine``; this raises ``ValueError`` if
the bank does not exist rather than silently discarding the overrides.
"""
normalized_updates = await self.validate_bank_config_updates(bank_id, updates, context)
await self._persist_bank_config(bank_id, normalized_updates)
async def _persist_bank_config(self, bank_id: str, normalized_updates: dict[str, Any]) -> None:
"""Persist already-validated overrides without changing bank lifecycle state."""
# Bank lifecycle belongs to MemoryEngine. Callers must create the row
# before reaching this persistence step. COALESCE guards against a NULL
# config column (NULL || jsonb is NULL), which would drop the override.
async with self._backend.acquire() as conn:
await ensure_bank_exists(conn, bank_id, ops=self._backend.ops)
await conn.execute(
result = await conn.execute(
f"""
UPDATE {fq_table("banks")}
SET config = COALESCE(config, '{{}}'::jsonb) || $1::jsonb,
@@ -464,6 +490,14 @@ class ConfigResolver:
bank_id,
)
# A missing bank row matches zero rows, which would otherwise persist
# nothing while reporting success. Fail loudly instead: reaching here
# without the row means a caller skipped the engine's provisioning step.
# (The Oracle wrapper reshapes rowcount into the same "UPDATE <n>" form.)
updated = int(result.split()[-1]) if isinstance(result, str) and result.startswith("UPDATE") else 0
if updated == 0:
raise ValueError(f"Cannot update config for bank '{bank_id}': the bank does not exist")
logger.info(f"Updated bank config for {bank_id}: {list(normalized_updates.keys())}")
async def reset_bank_config(self, bank_id: str) -> None:
@@ -10,7 +10,7 @@ import asyncio
import json
import logging
import uuid
from collections.abc import Callable
from collections.abc import Awaitable, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from datetime import datetime, timezone
@@ -19,6 +19,8 @@ from typing import Any
from pydantic import BaseModel, Field
from ..engine.db_utils import acquire_with_retry
from ..models import RequestContext
from .schema import fq_table_explicit
logger = logging.getLogger(__name__)
@@ -119,23 +121,60 @@ class AuditLogger:
schema_getter: Callable[[], str],
enabled: bool,
allowed_actions: list[str],
bank_enabled_resolver: Callable[[str, RequestContext | None], Awaitable[bool]] | None = None,
) -> 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
# Resolves the hierarchical ``audit_log_enabled`` for one bank
# (env -> tenant -> bank). None means "no per-bank resolution wired",
# in which case the global value alone decides.
self._bank_enabled_resolver = bank_enabled_resolver
def is_enabled(self, action: str) -> bool:
"""Check if audit logging is enabled for this action."""
if not self._enabled:
def action_allowed(self, action: str) -> bool:
"""Global action-allowlist check. Cheap, synchronous, bank-independent.
The allowlist is deployment-wide, so this is a valid pre-filter to skip
work for actions that can never be audited. It deliberately does NOT
consult the enabled flag: that is per-bank overridable, so a bank may
turn auditing ON even when the deployment default is off.
"""
if self._allowed_actions is None:
return True
return action in self._allowed_actions
async def should_log(self, action: str, bank_id: str | None, context: RequestContext | None = None) -> bool:
"""Full audit decision: action allowlist AND the bank's resolved switch.
``audit_log_enabled`` is hierarchical (env -> tenant -> bank), so the
effective value depends on which bank the action targets. Falls back to
the global value when there is no bank in scope or no resolver wired.
"""
if not self.action_allowed(action):
return False
if self._allowed_actions is not None:
return action in self._allowed_actions
return True
if bank_id is None or self._bank_enabled_resolver is None:
return self._enabled
try:
return await self._bank_enabled_resolver(bank_id, context)
except Exception as e:
# Never let a config-resolution failure break the request. Fall back
# to the deployment default: a transient DB blip must not silently
# create an audit gap for a bank meant to be audited. The tradeoff is
# the opt-out direction — a bank that overrode to false under a
# default-on deployment will be audited during the outage. We accept
# that: a few extra audit rows during a DB blip is the safer failure
# than dropping records that compliance may require.
logger.warning(f"Audit config resolution failed for bank={bank_id}: {e}; using global default")
return self._enabled
def log_fire_and_forget(self, entry: AuditEntry) -> None:
"""Schedule an audit write as a background task."""
if not self.is_enabled(entry.action):
"""Schedule an audit write as a background task.
Assumes the caller already made the audit decision via ``should_log``;
only the bank-independent allowlist is re-checked here.
"""
if not self.action_allowed(entry.action):
return
try:
asyncio.create_task(self._safe_log(entry))
@@ -150,8 +189,12 @@ class AuditLogger:
logger.debug("Audit log skipped: pool not available")
return
try:
schema = self._schema_getter()
table = f"{schema}.audit_log"
# fq_table_explicit qualifies per dialect: "schema".audit_log on
# PostgreSQL, bare audit_log on Oracle (where the schema is set at the
# session level). A raw f"{schema}.audit_log" produced public.audit_log
# on Oracle, where "public" is a reserved word — every write failed
# with ORA-00903 even though the table exists.
table = fq_table_explicit("audit_log", self._schema_getter())
async with acquire_with_retry(pool, max_retries=1) as conn:
await conn.execute(
f"""
@@ -182,6 +225,7 @@ async def audit_context(
bank_id: str | None = None,
request: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
context: RequestContext | None = None,
):
"""Async context manager that times the operation and writes audit on exit.
@@ -190,7 +234,7 @@ async def audit_context(
result = await do_work()
entry.response = result_dict
"""
if audit_logger is None or not audit_logger.is_enabled(action):
if audit_logger is None or not await audit_logger.should_log(action, bank_id, context):
entry = AuditEntry(action=action, transport=transport, bank_id=bank_id)
yield entry
return
@@ -13,6 +13,8 @@ but operators should opt in with that in mind.
from typing import Any
RERANKER_BANK_ID_HEADER = "X-Hindsight-Bank-Id"
def apply_bank_attribution(request: dict[str, Any]) -> None:
"""Tag ``request`` with ``user=<bank_id>`` for per-bank cost attribution.
@@ -32,3 +34,14 @@ def apply_bank_attribution(request: dict[str, Any]) -> None:
bank_id = get_current_bank_id()
if bank_id:
request["user"] = bank_id
def reranker_bank_attribution_headers() -> dict[str, str]:
"""Return the fixed per-bank header for trusted remote reranker endpoints."""
from ..config import get_config
from .memory_engine import get_current_bank_id
if not get_config().reranker_send_bank_as_header:
return {}
bank_id = get_current_bank_id()
return {RERANKER_BANK_ID_HEADER: bank_id} if bank_id else {}
@@ -0,0 +1,70 @@
"""Shared causal-link taxonomy.
Retain writes only the canonical relationship. Transfer import/export also
preserves historical relationship types so existing banks keep their graph
semantics without allowing new retain output to create those types.
"""
from dataclasses import dataclass
from typing import Any
CANONICAL_CAUSAL_LINK_TYPE = "caused_by"
LEGACY_CAUSAL_LINK_TYPE_NAMES = ("causes", "enables", "prevents")
CANONICAL_CAUSAL_LINK_TYPES = frozenset({CANONICAL_CAUSAL_LINK_TYPE})
LEGACY_CAUSAL_LINK_TYPES = frozenset(LEGACY_CAUSAL_LINK_TYPE_NAMES)
CAUSAL_LINK_TYPES = (CANONICAL_CAUSAL_LINK_TYPE, *LEGACY_CAUSAL_LINK_TYPE_NAMES)
DEFAULT_CAUSAL_LINK_WEIGHT = 1.0
@dataclass(frozen=True)
class CausalLinkDescriptor:
"""One causal edge, parked on the curation archive while an endpoint is invalidated.
Invalidation moves a fact out of ``memory_units``, so the FK cascade deletes
its ``memory_links`` rows — and nothing could recreate a causal edge, which
is extraction output rather than derived data. The descriptor is what the
archive row stores so revert can rematerialize the edge (#2864).
"""
from_unit_id: str
to_unit_id: str
link_type: str
weight: float = DEFAULT_CAUSAL_LINK_WEIGHT
def as_json_dict(self) -> dict[str, Any]:
"""Serializable form written to ``invalidated_memory_units.causal_links``.
The key names double as the column list of the ``jsonb_to_recordset``
read in ``snapshot_causal_links`` — keep them in sync.
"""
return {
"from_unit_id": self.from_unit_id,
"to_unit_id": self.to_unit_id,
"link_type": self.link_type,
"weight": self.weight,
}
@classmethod
def from_json_dict(cls, raw: Any) -> "CausalLinkDescriptor | None":
"""Parse one stored descriptor, or None when it isn't a usable causal edge.
The archive column is plain JSON with no schema enforcement (a restore
from an older backup, or a hand-edited row, can put anything there), and
``memory_links`` has a ``link_type`` CHECK constraint — so an unusable
entry is skipped rather than allowed to abort the whole revert.
"""
if not isinstance(raw, dict):
return None
from_unit_id = raw.get("from_unit_id")
to_unit_id = raw.get("to_unit_id")
link_type = raw.get("link_type")
if not from_unit_id or not to_unit_id or link_type not in CAUSAL_LINK_TYPES:
return None
return cls(
from_unit_id=str(from_unit_id),
to_unit_id=str(to_unit_id),
link_type=str(link_type),
weight=float(raw.get("weight") or DEFAULT_CAUSAL_LINK_WEIGHT),
)
@@ -109,6 +109,11 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
end.replace(hour=23, minute=59, second=59, microsecond=999999),
)
def safe_constraint(start: datetime | None, end: datetime | None) -> DateRange | NoTemporalConstraintSentinel:
if start is None or end is None:
return NO_TEMPORAL_CONSTRAINT
return constraint(start, end)
def subtract_months(months: int) -> datetime:
month_index = reference_date.month - months - 1
year = reference_date.year + month_index // 12
@@ -126,11 +131,21 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
day = min(base_date.day, calendar.monthrange(year, month)[1])
return base_date.replace(year=year, month=month, day=day)
def add_years(base_date: datetime, years: int) -> datetime:
def add_years(base_date: datetime, years: int) -> datetime | None:
year = base_date.year + years
if year < datetime.min.year or year > datetime.max.year:
return None
day = min(base_date.day, calendar.monthrange(year, base_date.month)[1])
return base_date.replace(year=year, day=day)
def add_days(base_date: datetime | None, days: int) -> datetime | None:
if base_date is None:
return None
try:
return base_date + timedelta(days=days)
except OverflowError:
return None
def has_chinese_temporal_context(match: re.Match[str]) -> bool:
if match.end() >= len(query):
return True
@@ -438,6 +453,11 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
return NO_TEMPORAL_CONSTRAINT
return constraint(start, reference_date)
def safe_since_constraint(start: datetime | None) -> DateRange | NoTemporalConstraintSentinel:
if start is None:
return NO_TEMPORAL_CONSTRAINT
return since_constraint(start)
def since_from_period(
period: DateRange | None,
) -> DateRange | NoTemporalConstraintSentinel | None:
@@ -450,7 +470,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
return None
return since_constraint(day)
def relative_offset_datetime(amount: int, unit: str, direction: int) -> datetime:
def relative_offset_datetime(amount: int, unit: str, direction: int) -> datetime | None:
if unit in ("", ""):
return reference_date + timedelta(days=direction * amount)
if unit in ("", "星期", "礼拜"):
@@ -459,15 +479,15 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
return add_months(reference_date, direction * amount)
return add_years(reference_date, direction * amount)
def point_constraint_at_offset(amount: int, unit: str, direction: int) -> DateRange:
def point_constraint_at_offset(amount: int, unit: str, direction: int) -> DateRange | NoTemporalConstraintSentinel:
d = relative_offset_datetime(amount, unit, direction)
return constraint(d, d)
return safe_constraint(d, d)
def window_to_reference(amount: int, unit: str) -> DateRange:
return constraint(relative_offset_datetime(amount, unit, -1), reference_date)
def window_to_reference(amount: int, unit: str) -> DateRange | NoTemporalConstraintSentinel:
return safe_constraint(relative_offset_datetime(amount, unit, -1), reference_date)
def window_from_reference(amount: int, unit: str) -> DateRange:
return constraint(reference_date, relative_offset_datetime(amount, unit, 1))
def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConstraintSentinel:
return safe_constraint(reference_date, relative_offset_datetime(amount, unit, 1))
# Chinese rule guide
#
@@ -781,8 +801,8 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
if relative_year_fixed_day_since_match:
year = relative_year_number(relative_year_fixed_day_since_match.group(1))
base = add_years(reference_date, year - reference_date.year)
d = base + timedelta(days=fixed_day_offset(relative_year_fixed_day_since_match.group(2)))
return since_constraint(d)
d = add_days(base, fixed_day_offset(relative_year_fixed_day_since_match.group(2)))
return safe_since_constraint(d)
fixed_day_since_match = chinese_search(
rf"(大大后天|大后天|后天|明天|明日|今天|今日|本日|当日|当天|昨天|昨日|大大前天|大前天|前天)"
@@ -799,7 +819,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
amount = parse_chinese_number(exact_relative_since_match.group(1))
unit = exact_relative_since_match.group(2)
if amount is not None:
return since_constraint(relative_offset_datetime(amount, unit, -1))
return safe_since_constraint(relative_offset_datetime(amount, unit, -1))
weekend_since_match = chinese_search(
rf"(?<![上下大小每个各隔])"
@@ -899,8 +919,8 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
if relative_year_daypart_since_match:
year = relative_year_number(relative_year_daypart_since_match.group(1))
base = add_years(reference_date, year - reference_date.year)
d = base + timedelta(days=daypart_day_offset(relative_year_daypart_since_match.group(2)))
return since_constraint(d)
d = add_days(base, daypart_day_offset(relative_year_daypart_since_match.group(2)))
return safe_since_constraint(d)
daypart_since_match = chinese_search(
rf"(昨晚|昨夜|前晚|前夜|今晚|今早|今晨|明早|明晚|明夜){chinese_since_suffix_pattern}"
@@ -915,17 +935,17 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
if relative_year_daypart_match:
year = relative_year_number(relative_year_daypart_match.group(1))
base = add_years(reference_date, year - reference_date.year)
d = base + timedelta(days=daypart_day_offset(relative_year_daypart_match.group(2)))
return constraint(d, d)
d = add_days(base, daypart_day_offset(relative_year_daypart_match.group(2)))
return safe_constraint(d, d)
# Day-part abbreviations still resolve only to date granularity.
if chinese_search(r"昨晚|昨夜"):
d = reference_date + timedelta(days=daypart_day_offset("昨晚"))
return constraint(d, d)
d = add_days(reference_date, daypart_day_offset("昨晚"))
return safe_constraint(d, d)
if chinese_search(r"前晚|前夜"):
d = reference_date + timedelta(days=daypart_day_offset("前晚"))
return constraint(d, d)
d = add_days(reference_date, daypart_day_offset("前晚"))
return safe_constraint(d, d)
if chinese_search(r"今晚|今早|今晨"):
return constraint(reference_date, reference_date)
@@ -941,8 +961,8 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
if relative_year_fixed_day_match:
year = relative_year_number(relative_year_fixed_day_match.group(1))
base = add_years(reference_date, year - reference_date.year)
d = base + timedelta(days=fixed_day_offset(relative_year_fixed_day_match.group(2)))
return constraint(d, d)
d = add_days(base, fixed_day_offset(relative_year_fixed_day_match.group(2)))
return safe_constraint(d, d)
if chinese_search(r"昨天|昨日"):
d = reference_date - timedelta(days=1)
@@ -1085,7 +1105,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
end_amount = parse_chinese_number(amount_text[-1])
unit = adjacent_fuzzy_future_match.group(2)
if start_amount is not None and end_amount is not None:
return constraint(
return safe_constraint(
relative_offset_datetime(start_amount, unit, 1),
relative_offset_datetime(end_amount, unit, 1),
)
@@ -1093,7 +1113,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
few_future_match = chinese_search(rf"[几数]个?(天|日|周|星期|礼拜|月|年){chinese_relative_future_suffix_pattern}")
if few_future_match:
unit = few_future_match.group(1)
return constraint(relative_offset_datetime(2, unit, 1), relative_offset_datetime(5, unit, 1))
return safe_constraint(relative_offset_datetime(2, unit, 1), relative_offset_datetime(5, unit, 1))
exact_future_match = chinese_search(
rf"(?<![{_CHINESE_NUMERAL_PREFIX_CHARS}])([0-9]+|[{_CHINESE_NUMERAL_CHARS}]+)个?(天|日|周|星期|礼拜|月|年){chinese_relative_future_suffix_pattern}"
@@ -1113,7 +1133,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
second_amount = parse_chinese_number(adjacent_fuzzy_past_match.group(2))
unit = adjacent_fuzzy_past_match.group(3)
if first_amount is not None and second_amount is not None and second_amount == first_amount + 1:
return constraint(
return safe_constraint(
relative_offset_datetime(second_amount, unit, -1),
relative_offset_datetime(first_amount, unit, -1),
)
@@ -1144,7 +1164,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
return constraint(reference_date - timedelta(days=150), reference_date - timedelta(days=60))
if chinese_search(r"一两年前|[两二]三年前|三两年前"):
return constraint(add_years(reference_date, -3), add_years(reference_date, -1))
return safe_constraint(add_years(reference_date, -3), add_years(reference_date, -1))
rolling_this_adjacent_match = chinese_search(
r"这(一两|[两二]三|三两|三四|四五|五六|六七|七八|八九|九十)个?(天|日|周|星期|礼拜|月|年)"
@@ -1154,7 +1174,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
end_amount = 3 if amount_text in ("一两", "三两") else parse_chinese_number(amount_text[-1])
unit = rolling_this_adjacent_match.group(2)
if end_amount is not None:
return constraint(relative_offset_datetime(end_amount, unit, -1), reference_date)
return safe_constraint(relative_offset_datetime(end_amount, unit, -1), reference_date)
rolling_this_count_match = chinese_search(rf"这([0-9]+|[{_CHINESE_NUMERAL_CHARS}]+)个?(天|日|周|星期|礼拜|月|年)")
if rolling_this_count_match:
@@ -1191,7 +1211,7 @@ def extract_chinese_period(query: str, reference_date: datetime) -> DateRange |
end_amount = 3 if amount_text in ("一两", "三两") else parse_chinese_number(amount_text[-1])
unit = rolling_past_adjacent_match.group(3)
if end_amount is not None:
return constraint(relative_offset_datetime(end_amount, unit, -1), reference_date)
return safe_constraint(relative_offset_datetime(end_amount, unit, -1), reference_date)
rolling_past_few_match = chinese_search(r"(过去|近|最近)几个?(天|日|周|星期|礼拜|月|年)")
if rolling_past_few_match:
@@ -28,6 +28,7 @@ from fnmatch import fnmatchcase
from itertools import combinations
from typing import TYPE_CHECKING, Any, Literal
import asyncpg
from pydantic import BaseModel, field_validator
from ...config import get_config
@@ -102,6 +103,29 @@ class _DedupDecision(BaseModel):
text: str = "" # the synthesized merged observation (when action == "merge")
reason: str = ""
@field_validator("action", mode="before")
@classmethod
def _normalize_action(cls, value: object) -> str:
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"merge", "keep"}:
return normalized
logger.warning("Invalid consolidation dedup action %r; defaulting to keep", value)
return "keep"
def _dedup_decision_from_response(raw: Any) -> _DedupDecision:
try:
if isinstance(raw, _DedupDecision):
return raw
if isinstance(raw, str):
return _DedupDecision.model_validate_json(raw)
return _DedupDecision.model_validate(raw)
except ValueError as exc:
logger.warning("Invalid consolidation dedup response %r; defaulting to keep: %s", raw, exc)
return _DedupDecision(action="keep", reason="invalid structured response")
_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:
@@ -109,9 +133,20 @@ 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"."""
Respond with ONLY one valid JSON object matching one of these shapes:
For duplicate facts:
{{"action": "merge", "text": "...", "reason": "..."}}
For distinct facts:
{{"action": "keep", "text": "", "reason": "..."}}
Do NOT use key=value lines, markdown fences, or any text outside the JSON object.
If they assert the SAME fact (wording aside), set "action" to "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 — set "action" to \
"keep" and "text" to an empty string."""
def _dedup_active(config: Any) -> bool:
@@ -174,7 +209,7 @@ async def _dedup_adjudicate(
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]
results = grouped["observation"].semantic
best_id: str | None = None
best_text = ""
best_sim = threshold # only candidates at/above the threshold are considered
@@ -189,10 +224,13 @@ async def _dedup_adjudicate(
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",
decision = _dedup_decision_from_response(
await dedup_llm_config.call(
messages=[{"role": "user", "content": _DEDUP_PROMPT.format(new=anchor_text, existing=best_text)}],
response_format=_DedupDecision,
scope="consolidation_dedup",
strict_schema=get_config().llm_strict_schema_consolidation,
)
)
if decision.action != "merge":
return _DedupOutcome(best_id=best_id, merged_text="", should_merge=False)
@@ -641,9 +679,19 @@ def _effective_scope_limit(config: Any, fact_tags: list[str]) -> int:
return config.max_observations_per_scope
def _build_response_model(max_creates: int | None = None) -> type[_ConsolidationBatchResponse]:
"""Build a response model, optionally constraining max creates via JSON schema."""
if max_creates is None or max_creates < 0:
def _build_response_model(
max_creates: int | None = None,
*,
supports_max_items: bool = True,
) -> type[_ConsolidationBatchResponse]:
"""Build a response model, optionally constraining creates via JSON schema.
Some structured-output backends (notably Bedrock Converse) reject the JSON
Schema ``maxItems`` keyword emitted by Pydantic's list ``max_length``. Operators
can disable the schema hint for those backends; the prompt capacity note and
post-response truncation still enforce the observation cap.
"""
if not supports_max_items or max_creates is None or max_creates < 0:
return _ConsolidationBatchResponse
from pydantic import Field as PydanticField
@@ -1766,15 +1814,22 @@ async def _append_observation_history(
history from growing without bound.
"""
obs_uuid = uuid.UUID(observation_id)
await conn.execute(
f"""
try:
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)),
)
obs_uuid,
bank_id,
json.dumps(asdict(snapshot)),
)
except asyncpg.exceptions.ForeignKeyViolationError:
logger.warning(
f"FK violation writing observation_history for {observation_id}: "
"observation was removed before history could be written (race with parallel consolidation). Skipping."
)
return
if max_entries and max_entries > 0:
await conn.execute(
f"""
@@ -2230,7 +2285,10 @@ async def _consolidate_batch_with_llm(
cached_prefix_name = None
# Use a constrained response model when observation limit is active
response_model = _build_response_model(max_creates=remaining_observation_slots)
response_model = _build_response_model(
max_creates=remaining_observation_slots,
supports_max_items=config.llm_supports_max_items,
)
max_attempts = config.consolidation_max_attempts
inner_max_retries = config.consolidation_llm_max_retries
@@ -2254,6 +2312,11 @@ async def _consolidate_batch_with_llm(
],
"response_format": response_model,
"scope": "consolidation",
# Resolved per operation (HINDSIGHT_API_LLM_STRICT_SCHEMA_CONSOLIDATION, falling
# back to the global flag) so an operator can grammar-enforce consolidation's
# structured output -- which narrows the raw-JSON failure mode behind #2668 --
# without forcing strict schema on operations whose model can't satisfy it.
"strict_schema": config.llm_strict_schema_consolidation,
}
# Only request an explicit output budget when configured. Left unset by default the key is
# omitted, so each provider keeps its implicit default (backwards compatible). Operators on
@@ -37,19 +37,38 @@ _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."""
# Field-by-field definitions of the input shape, shared by the cached system
# prefix (_INPUT_FORMAT_NOTE) and the single-message prompt (_INPUT_SECTION) so
# the two descriptions cannot drift apart. Both call sites run .format(), so
# these strings must contain no braces.
_FACT_FIELDS = """One per line, formatted as `[uuid] fact text (temporal fields)`:
- `[uuid]`: the fact's identifier — copy it verbatim into `source_fact_ids`
- `occurred_start` / `occurred_end`: when the described event happened. This can be long before the fact was stated — a fact recorded today may describe a 2019 event.
- `mentioned_at`: when the source material that states this fact was written. This is the fact's recency: how up to date the statement is, NOT when it was added to memory. A fact taken from an old document keeps its old `mentioned_at` even if it was only just processed."""
_OBSERVATION_FIELDS = """- `id`: unique identifier — copy this exactly when issuing an UPDATE or DELETE
- `text`: the observation content
- `proof_count`: how many source facts this observation has already merged
- `occurred_start` / `occurred_end`: the span of the events behind the observation — earliest start and latest end across its source facts
- `mentioned_at`: the latest of the `mentioned_at` values of its source facts — the most recent point at which this observation was stated
- `source_memories`: the supporting facts behind this observation. May be partial or absent for large observations — the count above remains the true total. Each entry carries the same `text` and temporal fields as a new fact, plus:
- `context`: optional surrounding context for that fact"""
# 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
_INPUT_FORMAT_NOTE = f"""## 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"""
Each request provides new facts and existing observations. Every temporal field is optional and is omitted when unknown.
### New facts
{_FACT_FIELDS}
### Existing observations
A JSON array pooled from recalls across the new facts. Each entry has:
{_OBSERVATION_FIELDS}"""
# 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
@@ -65,22 +84,22 @@ _SPLIT_INPUT_SECTION = """## INPUT
{observations_text}"""
# Data section — format placeholders {facts_text} and {observations_text} are substituted at call time
_INPUT_SECTION = """## INPUT
_INPUT_SECTION = f"""## INPUT
Every temporal field below is optional and is omitted when unknown.
### New facts
{facts_text}
{_FACT_FIELDS}
{{facts_text}}
### Existing observations
JSON array, pooled from recalls across all new facts above. 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
{_OBSERVATION_FIELDS}
{observations_text}"""
{{observations_text}}"""
_DECISION_GUIDE = """## DECISION GUIDE
@@ -12,6 +12,7 @@ import os
import warnings
from abc import ABC, abstractmethod
from concurrent.futures import ThreadPoolExecutor
from typing import Any
import httpx
@@ -45,47 +46,16 @@ from ..config import (
ENV_RERANKER_TEI_URL,
ENV_RERANKER_ZEROENTROPY_API_KEY,
)
from .bank_attribution import reranker_bank_attribution_headers
from .local_device import (
release_local_inference_memory,
resolve_model_device_type,
select_local_device,
)
logger = logging.getLogger(__name__)
def _resolve_malloc_trim():
"""Return a callable that asks glibc to release freed heap pages to the OS.
Local CPU rerankers (FlashRank/ONNX, SentenceTransformers/torch) allocate
large transient numpy/tensor buffers per call. On Linux glibc, those pages
are freed at the Python level but kept by the allocator as a high-water
mark — RSS grows monotonically across many recalls (see issue #1717).
Calling `malloc_trim(0)` after each batch returns those pages to the OS.
Resolved once at import; returns a no-op on non-glibc platforms (macOS,
musl, Windows) where the call is unavailable or unnecessary.
"""
import sys
if sys.platform != "linux":
return lambda: None
import ctypes
import ctypes.util
libc_path = ctypes.util.find_library("c")
if libc_path is None:
return lambda: None
try:
libc = ctypes.CDLL(libc_path)
trim = libc.malloc_trim
except (OSError, AttributeError):
# Not glibc (musl has no malloc_trim) or libc lookup failed.
return lambda: None
trim.argtypes = [ctypes.c_size_t]
trim.restype = ctypes.c_int
return lambda: trim(0)
_malloc_trim = _resolve_malloc_trim()
class CrossEncoderModel(ABC):
"""
Abstract base class for cross-encoder reranking.
@@ -150,6 +120,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
fp16: bool = False,
bucket_batching: bool = False,
batch_size: int = DEFAULT_RERANKER_LOCAL_BATCH_SIZE,
allow_mps: bool = False,
):
"""
Initialize local SentenceTransformers cross-encoder.
@@ -171,6 +142,9 @@ class LocalSTCrossEncoder(CrossEncoderModel):
Default: False (opt-in via env var).
batch_size: Batch size for predict() calls. Optimal values vary by
hardware and model (MPS: 32, CUDA: 128+). Default: 32.
allow_mps: Opt in to the Apple Silicon MPS GPU. Disabled by default
because MPS leaks memory under variable-length workloads
(see engine/local_device.py). Default: False
"""
self.model_name = model_name or DEFAULT_RERANKER_LOCAL_MODEL
self.force_cpu = force_cpu
@@ -178,7 +152,9 @@ class LocalSTCrossEncoder(CrossEncoderModel):
self.fp16 = fp16
self.bucket_batching = bucket_batching
self.batch_size = batch_size
self.allow_mps = allow_mps
self._model = None
self._device_type: str = "cpu"
LocalSTCrossEncoder._max_concurrent = max_concurrent
@property
@@ -200,33 +176,13 @@ class LocalSTCrossEncoder(CrossEncoderModel):
logger.info(f"Reranker: initializing local provider with model {self.model_name}")
# Determine device based on hardware availability.
# We always set low_cpu_mem_usage=False to prevent lazy loading (meta tensors)
# which can cause issues when accelerate is installed but no GPU is available.
# Determine device based on hardware availability. We always set
# low_cpu_mem_usage=False to prevent lazy loading (meta tensors) which can
# cause issues when accelerate is installed but no GPU is available.
# Note: We do NOT use device_map because CrossEncoder internally calls .to(device)
# after loading, which conflicts with accelerate's device_map handling.
import torch
# Force CPU mode if configured (used in daemon mode to avoid MPS/XPC issues on macOS)
if self.force_cpu:
device = "cpu"
logger.info("Reranker: forcing CPU mode (HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU=1)")
else:
# Check for GPU (CUDA), Apple Silicon (MPS), or Intel XPU
# Wrap in try-except to gracefully handle any device detection issues
# (e.g., in CI environments or when PyTorch is built without GPU support)
device = "cpu" # Default to CPU
try:
has_gpu = torch.cuda.is_available() or (
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
)
# Intel Arc XPU support — torch.xpu is available when the XPU build is loaded
if not has_gpu and hasattr(torch, "xpu"):
has_gpu = torch.xpu.is_available()
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS/XPU
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS/XPU, falling back to CPU: {e}")
# MPS is opt-in (allow_mps) — see engine/local_device.py for why.
device = select_local_device(self.force_cpu, self.allow_mps)
# Patch transformers 5.x compatibility for models using XLM-RoBERTa
# (e.g., jina-reranker-v2-base-multilingual). transformers 5.x removed
@@ -270,9 +226,11 @@ class LocalSTCrossEncoder(CrossEncoderModel):
# Restore original logging level
transformers_logger.setLevel(original_level)
self._device_type = resolve_model_device_type(self._model)
# FP16 inference: convert model weights to half precision.
# Empirically validated: 27-36% faster on MPS, quality-identical (20/20 overlap).
if self.fp16 and device != "cpu":
if self.fp16 and self._device_type != "cpu":
self._model.model.half()
logger.info("Reranker: FP16 inference enabled")
@@ -315,7 +273,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
scores = self._model.predict(pairs, batch_size=self.batch_size, show_progress_bar=False)
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
finally:
_malloc_trim()
release_local_inference_memory(self._device_type)
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
@@ -484,6 +442,7 @@ class RemoteTEICrossEncoder(CrossEncoderModel):
semaphore,
"POST",
f"{self.base_url}/rerank",
headers=reranker_bank_attribution_headers(),
json={
"query": query,
"texts": texts,
@@ -624,7 +583,11 @@ class _CohereCompatibleRerankClient:
if self.include_top_n:
body["top_n"] = len(texts)
response = await self._async_client.post(self.rerank_url, json=body)
response = await self._async_client.post(
self.rerank_url,
headers=reranker_bank_attribution_headers(),
json=body,
)
response.raise_for_status()
result = response.json()
@@ -919,6 +882,7 @@ class FlashRankCrossEncoder(CrossEncoderModel):
self.max_length = max_length
self.cpu_mem_arena = cpu_mem_arena
self._ranker = None
self._device_type: str = "cpu" # FlashRank runs on CPU via ONNX Runtime
FlashRankCrossEncoder._max_concurrent = max_concurrent
@property
@@ -990,11 +954,11 @@ class FlashRankCrossEncoder(CrossEncoderModel):
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Synchronous predict - processes each query group."""
from flashrank import RerankRequest
if not pairs:
return []
from flashrank import RerankRequest
try:
# Group pairs by query
query_groups: dict[str, list[tuple[int, str]]] = {}
@@ -1023,7 +987,7 @@ class FlashRankCrossEncoder(CrossEncoderModel):
return all_scores
finally:
_malloc_trim()
release_local_inference_memory(self._device_type)
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
@@ -1151,6 +1115,7 @@ class LiteLLMCrossEncoder(CrossEncoderModel):
# LiteLLM /rerank follows Cohere API format
response = await self._async_client.post(
f"{self.api_base}/rerank",
headers=reranker_bank_attribution_headers(),
json={
"model": self.model,
"query": query,
@@ -1269,10 +1234,11 @@ class LiteLLMSDKCrossEncoder(CrossEncoderModel):
indices = [idx for idx, _ in indexed_texts]
# Build kwargs for rerank call
rerank_kwargs = {
rerank_kwargs: dict[str, Any] = {
"model": self.model,
"query": query,
"documents": texts,
"headers": reranker_bank_attribution_headers(),
}
if self.api_key:
rerank_kwargs["api_key"] = self.api_key
@@ -1281,21 +1247,9 @@ class LiteLLMSDKCrossEncoder(CrossEncoderModel):
response = await self._litellm.arerank(**rerank_kwargs)
# Map scores back to original positions
# Response format: RerankResponse with results list
# Each result is a TypedDict with "index" and "relevance_score"
if hasattr(response, "results") and response.results:
for result in response.results:
# Results are TypedDicts, use dict-style access
original_idx = result["index"]
score = result.get("relevance_score", result.get("score", 0.0))
all_scores[indices[original_idx]] = score
elif isinstance(response, list):
# Direct list of scores (unlikely but defensive)
for i, score in enumerate(response):
all_scores[indices[i]] = score
else:
logger.warning(f"Unexpected response format from LiteLLM rerank: {type(response)}")
for result in response.results:
original_idx = result["index"]
all_scores[indices[original_idx]] = result["relevance_score"]
return all_scores
@@ -1647,6 +1601,7 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
fp16=config.reranker_local_fp16,
bucket_batching=config.reranker_local_bucket_batching,
batch_size=config.reranker_local_batch_size,
allow_mps=config.reranker_local_allow_mps,
)
elif provider == "cohere":
api_key = config.reranker_cohere_api_key
@@ -307,6 +307,17 @@ class DatabaseBackend(ABC):
"""Close the connection pool and release all resources."""
...
@property
@abstractmethod
def is_ready(self) -> bool:
"""Whether the pool exists and can serve connections.
False before :meth:`initialize` and after :meth:`shutdown`. Best-effort
callers (tracing, auditing) check this to skip work during those windows
instead of acquiring and interpreting the resulting error.
"""
...
@abstractmethod
@asynccontextmanager
async def acquire(self) -> AsyncIterator[DatabaseConnection]:
@@ -18,6 +18,7 @@ and mirrors Django's ``DatabaseOperations`` architecture.
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from .base import DatabaseConnection
@@ -172,6 +173,25 @@ class DataAccessOps(ABC):
"""
...
@abstractmethod
async def bulk_reassert_entities(
self,
conn: DatabaseConnection,
table: str,
bank_id: str,
entity_ids: list[str],
canonical_names: list[str],
) -> None:
"""Lock resolved parents and re-create any pruned since Phase-1 resolution.
Closes the retain Phase-1/prune race (#2662): existing rows are locked
(PG ``FOR KEY SHARE`` / Oracle ``FOR UPDATE``) so a concurrent
``prune_orphan_entities`` blocks until the caller's transaction commits,
while rows already deleted are re-inserted idempotently. ``entity_ids``
must be sorted by the caller for a stable lock order.
"""
...
@abstractmethod
async def bulk_insert_unit_entities(
self,
@@ -484,6 +504,23 @@ class DataAccessOps(ABC):
# -- Task claiming operations ------------------------------------------
@abstractmethod
async def prune_terminal_operations(
self,
conn: DatabaseConnection,
table: str,
cutoff: datetime,
*,
batch_size: int,
) -> int:
"""Delete one deterministic batch of terminal operations older than ``cutoff``.
Implementations must lock candidates without waiting on rows another
worker is pruning, never select pending/processing rows, and return the
number deleted. The caller provides a transaction around this method.
"""
...
@abstractmethod
async def claim_tasks(
self,
@@ -13,6 +13,8 @@ from .base import DatabaseConnection
from .ops import DataAccessOps, TagListingParts
from .result import DictResultRow as ResultRow
ORACLE_IN_LIST_LIMIT = 1000
class OracleOps(DataAccessOps):
"""Oracle-specific data access operations."""
@@ -216,7 +218,7 @@ class OracleOps(DataAccessOps):
for orig_name in missing_names:
row = await conn.fetchrow(
f"""
SELECT id, LOWER(canonical_name) AS name_lower
SELECT id, canonical_name, LOWER(canonical_name) AS name_lower
FROM {table}
WHERE bank_id = $1 AND LOWER(canonical_name) = LOWER($2)
""",
@@ -224,10 +226,37 @@ class OracleOps(DataAccessOps):
orig_name,
)
if row:
# Wrap in a dict-like to include input_name for downstream compat
results.append(row)
return results
async def bulk_reassert_entities(
self,
conn: DatabaseConnection,
table: str,
bank_id: str,
entity_ids: list[str],
canonical_names: list[str],
) -> None:
# Oracle has no FOR KEY SHARE; FOR UPDATE is the row-lock equivalent that
# blocks a concurrent prune DELETE until this transaction commits. Lock
# each surviving parent in the caller's stable id order (pruned ids are
# simply absent here), then re-insert any that vanished. The translation
# layer rewrites ON CONFLICT DO NOTHING to strip-and-catch ORA-00001, so
# a name recreated under a new id is suppressed rather than raising.
for entity_id in entity_ids:
await conn.fetchrow(
f"SELECT id FROM {table} WHERE id = $1 FOR UPDATE",
entity_id,
)
await conn.executemany(
f"""
INSERT INTO {table} (id, bank_id, canonical_name)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
""",
[(entity_id, bank_id, canonical_name) for entity_id, canonical_name in zip(entity_ids, canonical_names)],
)
async def bulk_insert_unit_entities(
self,
conn: DatabaseConnection,
@@ -329,6 +358,12 @@ class OracleOps(DataAccessOps):
entities_table: str,
bank_id: str,
) -> int:
# NB: the Postgres path additionally selects victims FOR UPDATE in sorted
# (entity_id_1, entity_id_2) order to prevent the #2529 deadlock against
# retain's sorted cooccurrence upsert. Oracle's DELETE can't carry that
# ordered-lock CTE the same way, so here we rely on the Pass 2/3 retry
# wrap in run_graph_maintenance_job (retry_with_backoff is ORA-00060
# deadlock-aware) to recover instead. Deliberate dialect asymmetry.
deleted = await conn.execute(
f"""
DELETE FROM {ec_table}
@@ -447,6 +482,14 @@ class OracleOps(DataAccessOps):
FROM {ue_table} ue_target
WHERE ue_target.entity_id = se.entity_id
AND ue_target.unit_id != ALL($1::uuid[])
-- Filter before applying the cap: candidates from other fact
-- types must not consume this entity's bounded fan-out.
AND EXISTS (
SELECT 1
FROM {mu_table} mu_target
WHERE mu_target.id = ue_target.unit_id
AND mu_target.fact_type = $2
)
ORDER BY ue_target.unit_id DESC
FETCH FIRST {per_entity_limit} ROWS ONLY
) t
@@ -459,7 +502,6 @@ class OracleOps(DataAccessOps):
es.score, 'entity' AS source
FROM entity_scores es
JOIN {mu_table} mu ON mu.id = es.unit_id
WHERE mu.fact_type = $2
ORDER BY es.score DESC
FETCH FIRST $3 ROWS ONLY
)"""
@@ -824,6 +866,157 @@ class OracleOps(DataAccessOps):
# -- Task claiming operations ------------------------------------------
async def prune_terminal_operations(
self,
conn: DatabaseConnection,
table: str,
cutoff: datetime,
*,
batch_size: int,
) -> int:
# Oracle rejects a row-limited SELECT ... FOR UPDATE (ORA-02014). Pick
# the deterministic bounded IDs first, then lock only that candidate
# set and re-check eligibility before deleting in the same transaction.
# Clamp to Oracle's 1000-expression IN-list limit because the adapter
# expands the candidate UUID list into individual bind variables.
# Cancelled children cannot complete parent aggregation, so retain the
# parent guard only for completed/failed children. Before removing a
# cancelled child, preserve its signal by cancelling a pending parent
# in this transaction and refreshing the parent's retention window.
# Validate metadata before HEXTORAW: CASE makes malformed UUIDs yield
# NULL while keeping the indexed RAW parent.operation_id key unwrapped.
effective_batch_size = min(batch_size, ORACLE_IN_LIST_LIMIT)
candidates = await conn.fetch(
f"""
SELECT candidate_operation.operation_id
FROM {table} candidate_operation
WHERE candidate_operation.status IN ('completed', 'failed', 'cancelled')
AND candidate_operation.updated_at < $1
AND (
candidate_operation.status = 'cancelled'
OR NOT EXISTS (
SELECT 1
FROM {table} parent
WHERE parent.operation_id = CASE
WHEN REGEXP_LIKE(
JSON_VALUE(
candidate_operation.result_metadata,
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
),
'^[0-9A-Fa-f]{{8}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{12}}$'
)
THEN HEXTORAW(REPLACE(
JSON_VALUE(
candidate_operation.result_metadata,
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
),
'-',
''
))
ELSE NULL
END
AND parent.bank_id = candidate_operation.bank_id
)
)
ORDER BY candidate_operation.updated_at, candidate_operation.operation_id
LIMIT $2
""",
cutoff,
effective_batch_size,
)
if not candidates:
return 0
candidate_ids = [row["operation_id"] for row in candidates]
locked = await conn.fetch(
f"""
SELECT candidate_operation.operation_id
FROM {table} candidate_operation
WHERE candidate_operation.operation_id = ANY($1)
AND candidate_operation.status IN ('completed', 'failed', 'cancelled')
AND candidate_operation.updated_at < $2
AND (
candidate_operation.status = 'cancelled'
OR NOT EXISTS (
SELECT 1
FROM {table} parent
WHERE parent.operation_id = CASE
WHEN REGEXP_LIKE(
JSON_VALUE(
candidate_operation.result_metadata,
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
),
'^[0-9A-Fa-f]{{8}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{12}}$'
)
THEN HEXTORAW(REPLACE(
JSON_VALUE(
candidate_operation.result_metadata,
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
),
'-',
''
))
ELSE NULL
END
AND parent.bank_id = candidate_operation.bank_id
)
)
ORDER BY candidate_operation.updated_at, candidate_operation.operation_id
FOR UPDATE OF candidate_operation.operation_id SKIP LOCKED
""",
candidate_ids,
cutoff,
)
if not locked:
return 0
operation_ids = [row["operation_id"] for row in locked]
await conn.execute(
f"""
UPDATE {table} parent
SET status = 'cancelled',
updated_at = now(),
completed_at = COALESCE(parent.completed_at, now()),
error_message = COALESCE(
parent.error_message,
'Cancelled because a child operation was cancelled'
)
WHERE parent.status = 'pending'
AND EXISTS (
SELECT 1
FROM {table} candidate_operation
WHERE candidate_operation.operation_id = ANY($1)
AND candidate_operation.status = 'cancelled'
AND candidate_operation.updated_at < $2
AND candidate_operation.bank_id = parent.bank_id
AND parent.operation_id = CASE
WHEN REGEXP_LIKE(
JSON_VALUE(
candidate_operation.result_metadata,
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
),
'^[0-9A-Fa-f]{{8}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{4}}-[0-9A-Fa-f]{{12}}$'
)
THEN HEXTORAW(REPLACE(
JSON_VALUE(
candidate_operation.result_metadata,
'$.parent_operation_id' RETURNING VARCHAR2(36) NULL ON ERROR
),
'-',
''
))
ELSE NULL
END
)
""",
operation_ids,
cutoff,
)
await conn.execute(
f"DELETE FROM {table} WHERE operation_id = ANY($1)",
operation_ids,
)
return len(operation_ids)
async def _claim_consolidation_tasks(
self,
conn,
@@ -4,11 +4,40 @@ Uses unnest(), LATERAL, DISTINCT ON, and native array operations for
efficient batch operations.
"""
from datetime import datetime
from .base import DatabaseConnection
from .ops import DataAccessOps, TagListingParts
from .result import ResultRow
def pg_search_vector_expr(
config,
*,
text_col: str = "text",
context_col: str = "context",
signals_col: str = "text_signals",
) -> str | None:
"""SQL expression that builds ``search_vector`` for the configured PG text-search backend.
Single source of truth shared by the batch insert (over the ``input_data``
CTE columns) and the curation revert recompute (over a ``memory_units`` row),
so the two can never drift. Returns ``None`` for backends that leave
``search_vector`` unpopulated — pgroonga / pg_textsearch / pg_search index the
base text columns directly and keep only a dummy column, so there is nothing
to build.
``text_search_extension_native_language`` is validated as a PG identifier in
``HindsightConfig.validate()``, so embedding it as a SQL literal is safe.
"""
combined = f"COALESCE({text_col}, '') || ' ' || COALESCE({context_col}, '') || ' ' || COALESCE({signals_col}, '')"
if config.text_search_extension == "vchord":
return f"tokenize({combined}, 'llmlingua2')::bm25_catalog.bm25vector"
if config.text_search_extension == "native":
return f"to_tsvector('{config.text_search_extension_native_language}'::regconfig, {combined})"
return None
class PostgreSQLOps(DataAccessOps):
"""PostgreSQL-specific data access operations using unnest and LATERAL."""
@@ -93,101 +122,39 @@ class PostgreSQLOps(DataAccessOps):
config = get_config()
table = self._get_mu_table()
if config.text_search_extension == "vchord":
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags,
observation_scopes, text_signals, search_vector)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json,
text_signals,
tokenize(
COALESCE(text, '') || ' ' || COALESCE(context, '') || ' ' || COALESCE(text_signals, ''),
'llmlingua2'
)::bm25_catalog.bm25vector
FROM input_data
RETURNING id
"""
elif config.text_search_extension == "native":
# search_vector is a regular tsvector column populated here using the
# configured native dictionary. It used to be GENERATED ALWAYS with
# a hardcoded 'english', which prevented per-deployment language
# configuration. text_search_extension_native_language is validated
# in HindsightConfig.validate() as a PG identifier, so embedding it
# as a SQL literal is safe.
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags,
observation_scopes, text_signals, search_vector)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json,
text_signals,
to_tsvector(
'{config.text_search_extension_native_language}'::regconfig,
COALESCE(text, '') || ' ' || COALESCE(context, '') || ' ' || COALESCE(text_signals, '')
)
FROM input_data
RETURNING id
"""
else:
# pg_textsearch, pgroonga, and pg_search: search_vector is a dummy
# TEXT column; the actual full-text index operates on the base text
# columns directly, so we don't populate search_vector at insert time.
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags,
observation_scopes, text_signals)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json,
text_signals
FROM input_data
RETURNING id
"""
# search_vector is populated inline for backends that store a real vector
# (native tsvector, vchord bm25vector). pgroonga / pg_textsearch / pg_search
# index the base text columns directly and keep only a dummy column, so the
# expression is None and the column is left out of the insert entirely.
# Same expression is reused by curation revert (see pg_search_vector_expr).
sv_expr = pg_search_vector_expr(config)
sv_insert_col = ", search_vector" if sv_expr else ""
sv_select_val = f",\n {sv_expr}" if sv_expr else ""
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::jsonb[], $11::text[], $12::text[], $13::jsonb[], $14::jsonb[], $15::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags_json,
observation_scopes_json, text_signals)
)
INSERT INTO {table} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id, tags,
observation_scopes, text_signals{sv_insert_col})
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, metadata, chunk_id, document_id,
COALESCE(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json,
text_signals{sv_select_val}
FROM input_data
RETURNING id
"""
results = await conn.fetch(
query,
@@ -286,11 +253,20 @@ class PostgreSQLOps(DataAccessOps):
entity_names: list[str],
entity_dates: list,
) -> dict[str, str]:
# ORDER BY LOWER(name) so every concurrent batch inserts in the same order
# as the conflict target (bank_id, LOWER(canonical_name)). ON CONFLICT DO
# NOTHING takes a ShareLock on the inserting transaction of any speculative
# row it collides with, so two batches with overlapping names inserting in
# different orders deadlock. The caller already sorts by Python's
# ``str.lower()``, which agrees with the index for ASCII but not for every
# locale (see the Turkish-İ note in entity_resolver) — ordering in SQL makes
# the database's own collation the single arbiter for all writers.
inserted_rows = await conn.fetch(
f"""
INSERT INTO {table} (bank_id, canonical_name, first_seen, last_seen, mention_count)
SELECT $1, name, COALESCE(event_date, now()), COALESCE(event_date, now()), 0
FROM unnest($2::text[], $3::timestamptz[]) AS t(name, event_date)
ORDER BY LOWER(name)
ON CONFLICT (bank_id, LOWER(canonical_name))
DO NOTHING
RETURNING id, LOWER(canonical_name) AS name_lower
@@ -310,7 +286,7 @@ class PostgreSQLOps(DataAccessOps):
) -> list[ResultRow]:
return await conn.fetch(
f"""
SELECT e.id, LOWER(e.canonical_name) AS name_lower, inputs.input_name
SELECT e.id, e.canonical_name, LOWER(e.canonical_name) AS name_lower, inputs.input_name
FROM {table} e
JOIN (
SELECT LOWER(n) AS input_name_lower, n AS input_name
@@ -322,6 +298,42 @@ class PostgreSQLOps(DataAccessOps):
missing_names,
)
async def bulk_reassert_entities(
self,
conn: DatabaseConnection,
table: str,
bank_id: str,
entity_ids: list[str],
canonical_names: list[str],
) -> None:
# One statement, one round-trip (same shape as bulk_insert_links):
# * the CTE takes FOR KEY SHARE on every parent that still exists,
# held to COMMIT, so a concurrent prune_orphan_entities DELETE blocks
# until the caller's unit_entities insert has committed;
# * the INSERT re-creates only the parents that were already pruned
# (NOT IN locked), carrying the canonical_name resolved in Phase 1.
# ON CONFLICT DO NOTHING (no target) keeps the rare case where another
# worker recreated the name under a new id from raising — that row stays
# absent and its unit link is the sole casualty, never the whole batch.
await conn.execute(
f"""
WITH locked AS (
SELECT id FROM {table}
WHERE id = ANY($2::uuid[])
ORDER BY id
FOR KEY SHARE
)
INSERT INTO {table} (id, bank_id, canonical_name)
SELECT t.entity_id, $1, t.canonical_name
FROM unnest($2::uuid[], $3::text[]) AS t(entity_id, canonical_name)
WHERE t.entity_id NOT IN (SELECT id FROM locked)
ON CONFLICT DO NOTHING
""",
bank_id,
entity_ids,
canonical_names,
)
async def bulk_insert_unit_entities(
self,
conn: DatabaseConnection,
@@ -425,19 +437,48 @@ class PostgreSQLOps(DataAccessOps):
# Scope by joining through entities.bank_id (entity_cooccurrences itself
# has no bank_id column — entities don't span banks, so scoping via
# entity_id_1 is sufficient).
#
# Ordered locking (deadlock avoidance, #2529): retain's concurrent
# cooccurrence upsert (entity_resolver._flush_pending) locks rows in
# sorted (entity_id_1, entity_id_2) order — sorted specifically to give
# every writer one consistent lock-acquisition order. A plain
# `DELETE ... USING` scans/locks in whatever order the join plan picks,
# so it could lock the same rows in the opposite order and cycle. We
# instead select the victims in that same sorted order `FOR UPDATE`
# first — the locking clause materialises the CTE and places LockRows
# above the Sort, so locks are acquired ascending, matching the upsert —
# then delete the already-locked rows. Same order on both sides ⇒ no
# cycle (the deadlock is prevented, not merely retried). The Pass 2/3
# retry wrap in run_graph_maintenance_job stays as a backstop for the
# residual paths (FK cascade from prune_orphan_entities, Oracle).
#
# The staleness predicate is an INTERSECT of the two entities' unit sets
# rather than the equivalent `unit_entities u1 JOIN u2 ON u1.unit_id =
# u2.unit_id` self-join (#2473): both INTERSECT branches resolve as Index
# Only Scans on idx_unit_entities_entity_unit (entity_id, unit_id), so the
# per-pair cost is bounded by the two entities' degrees. The self-join let
# the planner pick an anti-join that rescanned a high-degree hub entity's
# membership set for every pair — 28-30min on a bank with a ~100K-membership
# hub, even when zero rows were stale. Don't "simplify" it back.
result = await conn.execute(
f"""
WITH victims AS (
SELECT c.entity_id_1, c.entity_id_2
FROM {ec_table} c
JOIN {entities_table} e ON e.id = c.entity_id_1
WHERE e.bank_id = $1
AND NOT EXISTS (
SELECT unit_id FROM {ue_table} WHERE entity_id = c.entity_id_1
INTERSECT
SELECT unit_id FROM {ue_table} WHERE entity_id = c.entity_id_2
)
ORDER BY c.entity_id_1, c.entity_id_2
FOR UPDATE OF c
)
DELETE FROM {ec_table} c
USING {entities_table} e
WHERE e.id = c.entity_id_1
AND e.bank_id = $1
AND NOT EXISTS (
SELECT 1
FROM {ue_table} u1
JOIN {ue_table} u2 ON u1.unit_id = u2.unit_id
WHERE u1.entity_id = c.entity_id_1
AND u2.entity_id = c.entity_id_2
)
USING victims v
WHERE c.entity_id_1 = v.entity_id_1
AND c.entity_id_2 = v.entity_id_2
""",
bank_id,
)
@@ -449,11 +490,21 @@ class PostgreSQLOps(DataAccessOps):
mu_table: str,
unit_ids: list[str],
) -> list[ResultRow]:
# Cast only canonical UUID text inputs, never the indexed column. The old
# ``id::text`` predicate silently ignored malformed, uppercase, braced,
# and unhyphenated inputs; filtering before the cast preserves that
# behavior while allowing the primary-key index to serve the lookup.
return await conn.fetch(
f"""
SELECT id, event_date, fact_type
FROM {mu_table}
WHERE id::text = ANY($1)
WHERE id = ANY(
ARRAY(
SELECT input.unit_id::uuid
FROM unnest($1::text[]) AS input(unit_id)
WHERE input.unit_id ~ '^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$'
)
)
""",
unit_ids,
)
@@ -541,11 +592,18 @@ class PostgreSQLOps(DataAccessOps):
FROM {ue_table} ue_target
WHERE ue_target.entity_id = se.entity_id
AND ue_target.unit_id != ALL($1::uuid[])
-- Filter before applying the cap: candidates from other fact
-- types must not consume this entity's bounded fan-out.
AND EXISTS (
SELECT 1
FROM {mu_table} mu_target
WHERE mu_target.id = ue_target.unit_id
AND mu_target.fact_type = $2
)
ORDER BY ue_target.unit_id DESC
LIMIT {per_entity_limit}
) t
JOIN {mu_table} mu ON mu.id = t.unit_id
WHERE mu.fact_type = $2
GROUP BY mu.id
ORDER BY score DESC
LIMIT $3
@@ -761,10 +819,16 @@ class PostgreSQLOps(DataAccessOps):
internal_id: str,
fact_types: dict[str, str],
) -> None:
# CONCURRENTLY so the drop takes ShareUpdateExclusive, not ACCESS
# EXCLUSIVE, on the shared memory_units table. A plain DROP INDEX blocks
# (and deadlocks with) every other bank's concurrent reads/writes on the
# table; CONCURRENTLY does not conflict with DML. The caller
# (delete_bank) runs this on an autocommit connection after its delete
# transaction has committed — CONCURRENTLY cannot run inside a tx.
for ft, suffix in fact_types.items():
uid = str(internal_id).replace("-", "")[:16]
idx = f"idx_mu_emb_{suffix}_{uid}"
await conn.execute(f"DROP INDEX IF EXISTS {schema}.{idx}")
await conn.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}.{idx}")
def get_entity_resolution_strategy(self) -> str:
return "trigram"
@@ -894,6 +958,93 @@ class PostgreSQLOps(DataAccessOps):
# -- Task claiming operations ------------------------------------------
async def prune_terminal_operations(
self,
conn: DatabaseConnection,
table: str,
cutoff: datetime,
*,
batch_size: int,
) -> int:
# Lock only the bounded candidate set. SKIP LOCKED lets multiple
# workers prune disjoint batches without waiting or double-deleting.
# Cancelled children cannot complete parent aggregation, so retain the
# parent guard only for completed/failed children. Before removing a
# cancelled child, preserve its signal by cancelling a pending parent
# in this transaction and refreshing the parent's retention window.
candidates = await conn.fetch(
f"""
SELECT candidate_operation.operation_id
FROM {table} candidate_operation
WHERE candidate_operation.status IN ('completed', 'failed', 'cancelled')
AND candidate_operation.updated_at < $1
AND (
candidate_operation.status = 'cancelled'
OR NOT EXISTS (
SELECT 1
FROM {table} parent
WHERE parent.operation_id = CASE
WHEN candidate_operation.result_metadata->>'parent_operation_id'
~* '^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$'
THEN (candidate_operation.result_metadata->>'parent_operation_id')::uuid
ELSE NULL
END
AND parent.bank_id = candidate_operation.bank_id
)
)
ORDER BY candidate_operation.updated_at, candidate_operation.operation_id
LIMIT $2
FOR UPDATE OF candidate_operation SKIP LOCKED
""",
cutoff,
batch_size,
)
if not candidates:
return 0
candidate_ids = [row["operation_id"] for row in candidates]
await conn.execute(
f"""
UPDATE {table} parent
SET status = 'cancelled',
updated_at = now(),
completed_at = COALESCE(parent.completed_at, now()),
error_message = COALESCE(
parent.error_message,
'Cancelled because a child operation was cancelled'
)
WHERE parent.status = 'pending'
AND EXISTS (
SELECT 1
FROM {table} candidate_operation
WHERE candidate_operation.operation_id = ANY($1)
AND candidate_operation.status = 'cancelled'
AND candidate_operation.updated_at < $2
AND candidate_operation.bank_id = parent.bank_id
AND parent.operation_id = CASE
WHEN candidate_operation.result_metadata->>'parent_operation_id'
~* '^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$'
THEN (candidate_operation.result_metadata->>'parent_operation_id')::uuid
ELSE NULL
END
)
""",
candidate_ids,
cutoff,
)
rows = await conn.fetch(
f"""
DELETE FROM {table}
WHERE operation_id = ANY($1)
AND status IN ('completed', 'failed', 'cancelled')
AND updated_at < $2
RETURNING operation_id
""",
candidate_ids,
cutoff,
)
return len(rows)
async def _claim_consolidation_tasks(
self,
conn,
@@ -23,6 +23,8 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any, NamedTuple
from .pool_instrumentation import PoolStats, acquire_conn
class _OracleJSONEncoder(json.JSONEncoder):
"""JSON encoder that handles datetime and UUID objects."""
@@ -146,6 +148,7 @@ _JSON_COL_NAMES = {
"config",
"observation_scopes",
"source_memory_ids",
"causal_links",
"trigger",
"http_config",
"event_types",
@@ -1242,6 +1245,11 @@ class OracleBackend(DatabaseBackend):
def __init__(self) -> None:
self._pool: Any = None
self._oracledb: Any = None
# Oracle pooled sessions retain CURRENT_SCHEMA across checkouts. Cache
# SESSION_USER so default-schema acquisitions can explicitly reset a
# connection that was previously used for a tenant schema.
self._default_schema: str | None = None
self._acquire_warn_threshold_s: float = 1.0
async def initialize(
self,
@@ -1257,6 +1265,10 @@ class OracleBackend(DatabaseBackend):
oracledb = _import_oracledb()
self._oracledb = oracledb
from ...config import get_config
self._acquire_warn_threshold_s = get_config().db_acquire_warn_threshold_ms / 1000.0
# Parse URL-format DSN (oracle://user:pass@host:port/service)
from urllib.parse import urlparse
@@ -1277,11 +1289,17 @@ class OracleBackend(DatabaseBackend):
logger.info(f"Oracle pool created (min={min_size}, max={max_size})")
async def shutdown(self) -> None:
if self._pool is not None:
await self._pool.close(force=True)
self._pool = None
# Drop the reference before awaiting close() so is_ready flips False for
# the whole teardown, not just after it completes (see PostgreSQLBackend).
pool, self._pool = self._pool, None
if pool is not None:
await pool.close(force=True)
logger.info("Oracle pool closed")
@property
def is_ready(self) -> bool:
return self._pool is not None
async def _set_session_schema(self, conn: Any) -> None:
"""Set the session schema on an Oracle connection.
@@ -1294,15 +1312,41 @@ class OracleBackend(DatabaseBackend):
from ..memory_engine import get_current_schema
schema = get_current_schema()
if schema and schema != "public":
cursor = conn.cursor()
await cursor.execute(f'ALTER SESSION SET CURRENT_SCHEMA = "{schema}"')
await cursor.close()
cursor = conn.cursor()
try:
if self._default_schema is None:
await cursor.execute("SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM DUAL")
row = await cursor.fetchone()
if not row or not row[0]:
raise RuntimeError("Oracle did not return SESSION_USER while resetting CURRENT_SCHEMA")
self._default_schema = str(row[0])
target_schema = self._default_schema if not schema or schema == "public" else schema
safe_schema = target_schema.replace('"', '""')
await cursor.execute(f'ALTER SESSION SET CURRENT_SCHEMA = "{safe_schema}"')
finally:
# oracledb's AsyncCursor.close() is synchronous (not a coroutine);
# awaiting it raises "object NoneType can't be used in 'await'
# expression" and aborts every acquire().
cursor.close()
def _pool_stats(self) -> PoolStats | None:
"""Snapshot for slow-acquire logs, from oracledb pool attributes."""
pool = self._pool
if pool is None:
return None
try:
busy = pool.busy
return PoolStats(in_use=busy, max=pool.max, idle=pool.opened - busy)
except Exception:
return None
@asynccontextmanager
async def acquire(self) -> AsyncIterator[OracleConnection]:
pool = self._ensure_pool()
conn = await pool.acquire()
conn = await acquire_conn(
pool.acquire(), pool_stats=self._pool_stats, warn_threshold_s=self._acquire_warn_threshold_s
)
try:
await self._set_session_schema(conn)
yield OracleConnection(conn)
@@ -1318,7 +1362,9 @@ class OracleBackend(DatabaseBackend):
@asynccontextmanager
async def transaction(self) -> AsyncIterator[OracleConnection]:
pool = self._ensure_pool()
conn = await pool.acquire()
conn = await acquire_conn(
pool.acquire(), pool_stats=self._pool_stats, warn_threshold_s=self._acquire_warn_threshold_s
)
try:
await self._set_session_schema(conn)
yield OracleConnection(conn)
@@ -0,0 +1,137 @@
"""Instrumentation for database connection-pool acquisition.
asyncpg exposes pool *size* and *idle* counts, but not how many callers are
currently **queued waiting** for a connection — and that queue depth is the
signal that actually distinguishes a saturated pool from a healthy one. When the
pool is exhausted, ``/health`` (which itself acquires a connection to run
``SELECT 1``) blocks in ``pool.acquire()`` until a connection frees or the acquire
times out, so a liveness probe can fail **with the event loop completely idle**.
This module tracks the process-wide count of in-flight acquisitions that have not
yet obtained a connection, and times each acquire so a slow one logs with full
pool stats. It is the DB-side counterpart to ``loop_watchdog`` (which covers loop
stalls); together, a stuck ``/health`` can be attributed to either a blocked loop
or pool exhaustion from the logs alone.
The counter is a plain int mutated only from the event-loop thread (asyncpg
acquisitions are awaited on the loop), so no lock is needed.
"""
from __future__ import annotations
import logging
import time
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger("hindsight.db.pool")
_waiting = 0 # callers currently blocked in pool.acquire(), process-wide
@dataclass(frozen=True, slots=True)
class PoolStats:
"""Point-in-time connection-pool utilization snapshot."""
in_use: int
max: int
idle: int
def waiting_count() -> int:
"""Number of callers currently blocked waiting to acquire a pooled connection."""
return _waiting
@asynccontextmanager
async def instrument_acquire(
acquire_cm: Any,
*,
pool_stats: Callable[[], PoolStats | None] | None = None,
warn_threshold_s: float,
) -> AsyncIterator[Any]:
"""Wrap a pool's ``acquire()`` context manager with wait tracking + slow-acquire logging.
Args:
acquire_cm: an async context manager yielding a connection (e.g. the object
returned by ``asyncpg.Pool.acquire()``).
pool_stats: optional zero-arg callable returning a ``PoolStats`` snapshot for
the slow-acquire log line.
warn_threshold_s: log a warning when the acquire itself takes at least this long.
Yields:
The acquired connection.
"""
global _waiting
_waiting += 1
start = time.monotonic()
acquired = False
try:
async with acquire_cm as conn:
acquired = True
_waiting -= 1
_record_acquire_wait(time.monotonic() - start, pool_stats, warn_threshold_s)
yield conn
finally:
# If __aenter__ raised (acquire timeout / cancellation), we never
# decremented above — do it here so the waiter count can't leak.
if not acquired:
_waiting -= 1
async def acquire_conn(
acquire_awaitable: Any,
*,
pool_stats: Callable[[], PoolStats | None] | None = None,
warn_threshold_s: float,
) -> Any:
"""Await a pool acquire that returns a connection, with wait tracking + slow log.
For pools whose acquire is ``conn = await pool.acquire()`` (oracledb) rather than
an async context manager (asyncpg — use ``instrument_acquire`` for those). The
caller is responsible for releasing the returned connection.
"""
global _waiting
_waiting += 1
start = time.monotonic()
try:
conn = await acquire_awaitable
finally:
_waiting -= 1
_record_acquire_wait(time.monotonic() - start, pool_stats, warn_threshold_s)
return conn
def _record_acquire_wait(
wait_s: float,
pool_stats: Callable[[], PoolStats | None] | None,
warn_threshold_s: float,
) -> None:
try:
from ...metrics import get_metrics_collector
get_metrics_collector().record_db_acquire_wait(wait_s)
except Exception:
pass
if wait_s < warn_threshold_s:
return
stats: PoolStats | None = None
if pool_stats is not None:
try:
stats = pool_stats()
except Exception:
stats = None
logger.warning(
"slow DB pool acquire: waited %.3fs for a connection "
"(in_use=%s max=%s idle=%s waiting=%s). The pool is likely saturated; "
"/health can stall on connection acquisition while the event loop is free.",
wait_s,
stats.in_use if stats else None,
stats.max if stats else None,
stats.idle if stats else None,
_waiting,
)
@@ -15,6 +15,7 @@ from typing import Any
import asyncpg # noqa: F401
from .base import DatabaseBackend, DatabaseConnection
from .pool_instrumentation import PoolStats, instrument_acquire
logger = logging.getLogger(__name__)
@@ -76,6 +77,8 @@ class PostgreSQLBackend(DatabaseBackend):
def __init__(self) -> None:
self._pool: asyncpg.Pool | None = None
self._acquire_warn_threshold_s: float = 1.0
self._acquire_timeout_s: float | None = None
async def initialize(
self,
@@ -88,6 +91,16 @@ class PostgreSQLBackend(DatabaseBackend):
statement_cache_size: int = 0,
init_callback: Any | None = None,
) -> None:
from ...config import get_config
self._acquire_warn_threshold_s = get_config().db_acquire_warn_threshold_ms / 1000.0
# Kept for acquire() below: asyncpg's ``timeout`` create_pool kwarg is a
# *connect* kwarg (how long establishing a new connection may take), and
# ``Pool.acquire()`` defaults to waiting for a free connection forever.
# Passing it here alone made HINDSIGHT_API_DB_ACQUIRE_TIMEOUT a no-op for
# the wait it names: a pool-exhaustion stall never surfaced as an error,
# it just hung (#3002). 0 restores the unbounded behaviour.
self._acquire_timeout_s = acquire_timeout if acquire_timeout > 0 else None
self._pool = await asyncpg.create_pool(
dsn,
min_size=min_size,
@@ -95,7 +108,12 @@ class PostgreSQLBackend(DatabaseBackend):
command_timeout=command_timeout,
statement_cache_size=statement_cache_size,
timeout=acquire_timeout,
# init runs once per new connection; setup runs on every acquire,
# after asyncpg's release-time RESET ALL. Passing init_callback as
# both keeps the per-connection session GUCs (hnsw.ef_search, etc.)
# applied after a connection is reused, not just on first creation.
init=init_callback,
setup=init_callback,
)
logger.info(
f"PostgreSQL pool created (min={min_size}, max={max_size}, "
@@ -103,21 +121,45 @@ class PostgreSQLBackend(DatabaseBackend):
)
async def shutdown(self) -> None:
if self._pool is not None:
await self._pool.close()
self._pool = None
# Drop the reference *before* awaiting close(): closing is not
# instantaneous, and anything acquiring during that window would
# otherwise get an asyncpg "pool is closing" error rather than seeing
# is_ready False.
pool, self._pool = self._pool, None
if pool is not None:
await pool.close()
logger.info("PostgreSQL pool closed")
@property
def is_ready(self) -> bool:
return self._pool is not None
def _pool_stats(self) -> PoolStats | None:
"""Snapshot for slow-acquire logs. in_use = live connections minus idle ones."""
pool = self._pool
if pool is None:
return None
idle = pool.get_idle_size()
return PoolStats(in_use=pool.get_size() - idle, max=pool.get_max_size(), idle=idle)
@asynccontextmanager
async def acquire(self) -> AsyncIterator[PostgresConnection]:
pool = self._ensure_pool()
async with pool.acquire() as conn:
async with instrument_acquire(
pool.acquire(timeout=self._acquire_timeout_s),
pool_stats=self._pool_stats,
warn_threshold_s=self._acquire_warn_threshold_s,
) as conn:
yield PostgresConnection(conn)
@asynccontextmanager
async def transaction(self) -> AsyncIterator[PostgresConnection]:
pool = self._ensure_pool()
async with pool.acquire() as conn:
async with instrument_acquire(
pool.acquire(timeout=self._acquire_timeout_s),
pool_stats=self._pool_stats,
warn_threshold_s=self._acquire_warn_threshold_s,
) as conn:
async with conn.transaction():
yield PostgresConnection(conn)
@@ -4,6 +4,7 @@ Database utility functions for connection management with retry logic.
import asyncio
import logging
import random
import time
from collections.abc import AsyncIterator
from contextlib import AsyncExitStack, asynccontextmanager
@@ -16,6 +17,20 @@ DEFAULT_MAX_RETRIES = 3
DEFAULT_BASE_DELAY = 0.5 # seconds
DEFAULT_MAX_DELAY = 5.0 # seconds
def _backoff_delay(attempt: int, base_delay: float, max_delay: float) -> float:
"""Exponential backoff with equal jitter.
Deterministic backoff makes concurrent retriers wake in lock-step and
re-collide on the very same rows, re-triggering the deadlock they just
backed off from. "Equal jitter" — half the window fixed, half random —
keeps a floor (so we don't hot-spin) while decorrelating the wake-ups, so
two contenders that deadlocked together are very unlikely to retry in sync.
"""
ceil = min(base_delay * (2**attempt), max_delay)
return ceil / 2 + random.uniform(0, ceil / 2)
# Retryable exception types (checked by class name to avoid hard imports)
_RETRYABLE_EXCEPTION_NAMES = frozenset(
{
@@ -78,7 +93,7 @@ async def retry_with_backoff(
raise
last_exception = e
if attempt < max_retries:
delay = min(base_delay * (2**attempt), max_delay)
delay = _backoff_delay(attempt, base_delay, max_delay)
if type(e).__name__ == "DeadlockDetectedError" or _is_oracle_deadlock(e):
logger.warning(
"Deadlock detected during parallel document processing — "
@@ -136,7 +151,7 @@ async def acquire_with_retry(backend_or_pool: Any, max_retries: int = DEFAULT_MA
if not _is_retryable(e):
raise
if attempt < max_retries:
delay = min(DEFAULT_BASE_DELAY * (2**attempt), DEFAULT_MAX_DELAY)
delay = _backoff_delay(attempt, DEFAULT_BASE_DELAY, DEFAULT_MAX_DELAY)
logger.warning(
f"Database acquire failed (attempt {attempt + 1}/{max_retries + 1}): {e}. "
f"Retrying in {delay:.1f}s..."
@@ -48,6 +48,11 @@ from ..config import (
ENV_LLM_API_KEY,
)
from .bank_attribution import apply_bank_attribution
from .local_device import (
release_local_inference_memory,
resolve_model_device_type,
select_local_device,
)
logger = logging.getLogger(__name__)
@@ -76,6 +81,25 @@ class _ZeroEntropyEmbedResponse(BaseModel):
results: list[_ZeroEntropyEmbedResult]
def _truncate_to_tokens(text: str, max_tokens: int) -> tuple[str, int]:
"""Truncate ``text`` to at most ``max_tokens`` cl100k_base tokens.
tiktoken is an approximation of any given provider's tokenizer, so set
``max_tokens`` with a little headroom below the model's real limit.
Returns the (possibly truncated) text and the original token count (so the
caller can report how much was dropped); the count equals ``len(tokens)``
whether or not truncation occurred.
"""
from .token_encoding import get_token_encoding
enc = get_token_encoding()
tokens = enc.encode(text)
if len(tokens) <= max_tokens:
return text, len(tokens)
return enc.decode(tokens[:max_tokens]), len(tokens)
class Embeddings(ABC):
"""
Abstract base class for embedding generation.
@@ -136,7 +160,13 @@ class LocalSTEmbeddings(Embeddings):
The embedding dimension is auto-detected from the model.
"""
def __init__(self, model_name: str | None = None, force_cpu: bool = False, trust_remote_code: bool = False):
def __init__(
self,
model_name: str | None = None,
force_cpu: bool = False,
trust_remote_code: bool = False,
allow_mps: bool = False,
):
"""
Initialize local SentenceTransformers embeddings.
@@ -148,12 +178,17 @@ class LocalSTEmbeddings(Embeddings):
trust_remote_code: Allow loading models with custom code (security risk).
Required for some models with custom architectures.
Default: False (disabled for security)
allow_mps: Opt in to the Apple Silicon MPS GPU. Disabled by default
because MPS leaks memory under variable-length workloads
(see engine/local_device.py). Default: False
"""
self.model_name = model_name or DEFAULT_EMBEDDINGS_LOCAL_MODEL
self.force_cpu = force_cpu
self.trust_remote_code = trust_remote_code
self.allow_mps = allow_mps
self._model = None
self._dimension: int | None = None
self._device_type: str = "cpu"
@property
def provider_name(self) -> str:
@@ -180,31 +215,11 @@ class LocalSTEmbeddings(Embeddings):
logger.info(f"Embeddings: initializing local provider with model {self.model_name}")
# Determine device based on hardware availability.
# We always set low_cpu_mem_usage=False to prevent lazy loading (meta tensors)
# which can cause issues when accelerate is installed but no GPU is available.
import torch
# Force CPU mode if configured (used in daemon mode to avoid MPS/XPC issues on macOS)
if self.force_cpu:
device = "cpu"
logger.info("Embeddings: forcing CPU mode")
else:
# Check for GPU (CUDA), Apple Silicon (MPS), or Intel XPU
# Wrap in try-except to gracefully handle any device detection issues
# (e.g., in CI environments or when PyTorch is built without GPU support)
device = "cpu" # Default to CPU
try:
has_gpu = torch.cuda.is_available() or (
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
)
# Intel Arc XPU support — torch.xpu is available when the XPU build is loaded
if not has_gpu and hasattr(torch, "xpu"):
has_gpu = torch.xpu.is_available()
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS/XPU
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS/XPU, falling back to CPU: {e}")
# Determine device based on hardware availability. We always set
# low_cpu_mem_usage=False to prevent lazy loading (meta tensors) which can
# cause issues when accelerate is installed but no GPU is available.
# MPS is opt-in (allow_mps) — see engine/local_device.py for why.
device = select_local_device(self.force_cpu, self.allow_mps)
# Suppress verbose transformers warnings during model loading
# This suppresses the "UNEXPECTED" warnings from BertModel which are harmless
@@ -231,7 +246,8 @@ class LocalSTEmbeddings(Embeddings):
transformers_logger.setLevel(original_level)
self._dimension = self._model.get_sentence_embedding_dimension()
logger.info(f"Embeddings: local provider initialized (dim: {self._dimension})")
self._device_type = resolve_model_device_type(self._model)
logger.info(f"Embeddings: local provider initialized (dim: {self._dimension}, device: {self._device_type})")
def encode(self, texts: list[str]) -> list[list[float]]:
"""
@@ -246,8 +262,19 @@ class LocalSTEmbeddings(Embeddings):
if self._model is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
embeddings = self._model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
return [emb.tolist() for emb in embeddings]
try:
embeddings = self._model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
return [emb.tolist() for emb in embeddings]
finally:
# Only reclaim the GPU allocator pool here, and only when actually on a
# GPU (opt-in MPS/CUDA/XPU). encode() runs in tight retain loops, so a
# gc.collect()/malloc_trim on every call is too costly on the CPU default
# — and unnecessary: refcounting frees the small transient buffers
# immediately and the allocator reuses them for the next batch. (The
# reranker keeps its per-batch heap trim for the #1717 CPU case; it runs
# on the lighter recall path.) See engine/local_device.py.
if self._device_type != "cpu":
release_local_inference_memory(self._device_type)
class OnnxEmbeddings(Embeddings):
@@ -1202,6 +1229,7 @@ class LiteLLMSDKEmbeddings(Embeddings):
batch_size: int = 100,
timeout: float = 60.0,
encoding_format: str | None = "float",
max_input_tokens: int | None = None,
):
"""
Initialize LiteLLM SDK embeddings client.
@@ -1216,6 +1244,10 @@ class LiteLLMSDKEmbeddings(Embeddings):
timeout: Request timeout in seconds (default: 60.0)
encoding_format: Encoding format for embeddings (default: "float").
Set to None or empty string to omit (needed for Voyage AI, Gemini).
max_input_tokens: If set, truncate each input text to this many tokens
(tiktoken cl100k_base) before embedding. Needed for models with a
fixed input-token limit (e.g. Bedrock Titan V2's hard 8192 cap),
where an oversized text would otherwise fail permanently (#2501).
"""
self.api_key = api_key
self.model = model
@@ -1224,6 +1256,7 @@ class LiteLLMSDKEmbeddings(Embeddings):
self.batch_size = batch_size
self.timeout = timeout
self.encoding_format = encoding_format or None
self.max_input_tokens = max_input_tokens
self._litellm = None # Will be set during initialization
self._dimension: int | None = None
@@ -1300,6 +1333,33 @@ class LiteLLMSDKEmbeddings(Embeddings):
if not texts:
return []
# Truncate oversized inputs before hitting the provider. Models with a
# fixed input-token limit (e.g. Bedrock Titan V2, 8192) reject an
# oversized text with a permanent error rather than truncating it
# server-side, which strands the caller (e.g. a delta mental model whose
# content grew past the cap) with no recovery path. See #2501.
if self.max_input_tokens is not None:
truncated_texts = []
original_token_counts = []
for t in texts:
new_text, original_tokens = _truncate_to_tokens(t, self.max_input_tokens)
truncated_texts.append(new_text)
if original_tokens > self.max_input_tokens:
original_token_counts.append(original_tokens)
texts = truncated_texts
if original_token_counts:
logger.warning(
"Embeddings: truncated %d of %d input(s) to %d tokens for model %s "
"(largest was ~%d tokens); embedded content is incomplete. "
"This usually means a mental model's content has grown past the model's "
"input limit — see issue #2501.",
len(original_token_counts),
len(texts),
self.max_input_tokens,
self.model,
max(original_token_counts),
)
all_embeddings = []
# Process in batches
@@ -1585,6 +1645,7 @@ def create_embeddings_from_env() -> Embeddings:
model_name=config.embeddings_local_model,
force_cpu=config.embeddings_local_force_cpu,
trust_remote_code=config.embeddings_local_trust_remote_code,
allow_mps=config.embeddings_local_allow_mps,
)
elif provider == "onnx":
return OnnxEmbeddings(
@@ -1691,6 +1752,7 @@ def create_embeddings_from_env() -> Embeddings:
api_base=config.embeddings_litellm_sdk_api_base,
output_dimensions=config.embeddings_litellm_sdk_output_dimensions,
encoding_format=config.embeddings_litellm_sdk_encoding_format,
max_input_tokens=config.embeddings_litellm_sdk_max_input_tokens,
)
elif provider == "google":
vertexai_project_id = config.embeddings_vertexai_project_id
@@ -9,10 +9,11 @@ import asyncio
import json
import logging
from collections import defaultdict
from collections.abc import Iterator
from dataclasses import dataclass, field
from datetime import UTC, datetime
from difflib import SequenceMatcher
from typing import Any, Final
from typing import Any, Final, cast
from .db_utils import acquire_with_retry
from .memory_engine import fq_table
@@ -25,6 +26,7 @@ from .retain.entity_labels import (
from .retain.entity_labels import (
parse_entity_labels as _parse_entity_labels,
)
from .retain.types import ResolvedEntity
logger = logging.getLogger(__name__)
@@ -75,6 +77,22 @@ def _later_date(a: datetime | None, b: datetime | None) -> datetime | None:
return a if a > b else b
def _canonical_cooccurrence_pairs(entity_list: list[str]) -> Iterator[tuple[str, str]]:
"""Yield each distinct pair of ``entity_list`` as ``(a, b)`` with ``a < b``.
Canonical ordering matches the entity_cooccurrences PK and check constraint.
The pair is ordered into fresh locals rather than by swapping the loop
variables: ``entity_id_1`` is the outer iterate, so swapping it would leak
into the remaining inner iterations and build later pairs off the wrong
element.
"""
for i, entity_id_1 in enumerate(entity_list):
for entity_id_2 in entity_list[i + 1 :]:
if entity_id_1 == entity_id_2:
continue
yield (entity_id_1, entity_id_2) if entity_id_1 < entity_id_2 else (entity_id_2, entity_id_1)
@dataclass
class _CooccurrencePair:
"""A (entity_id_1, entity_id_2) pair observed in a retain batch (for post-txn flush)."""
@@ -230,7 +248,7 @@ class EntityResolver:
unit_event_date,
conn=None,
entity_labels: list | None = None,
) -> list[str]:
) -> list[ResolvedEntity]:
"""
Resolve multiple entities in batch (MUCH faster than sequential).
@@ -245,7 +263,8 @@ class EntityResolver:
conn: Optional connection to use (if None, acquires from pool)
Returns:
List of entity IDs in same order as input
Resolved entity identities (id + stored canonical name) in the same
order as input.
"""
if not entities_data:
return []
@@ -271,7 +290,7 @@ class EntityResolver:
unit_event_date,
taxonomy_lookup: set[str] | None = None,
labels_cfg=None,
) -> list[str]:
) -> list[ResolvedEntity]:
if self.entity_lookup == "trigram":
# Route to backend-specific fuzzy strategy.
# Non-PG backends (Oracle) use UTL_MATCH instead of pg_trgm.
@@ -311,7 +330,7 @@ class EntityResolver:
unit_event_date,
taxonomy_lookup: set[str] | None = None,
labels_cfg=None,
) -> list[str]:
) -> list[ResolvedEntity]:
"""Original strategy: load all bank entities then match in Python."""
# Query ALL candidates for this bank
all_entities = await conn.fetch(
@@ -395,7 +414,7 @@ class EntityResolver:
unit_event_date,
taxonomy_lookup: set[str] | None = None,
labels_cfg=None,
) -> list[str]:
) -> list[ResolvedEntity]:
"""
Trigram strategy: fetch only similar candidates per entity name using pg_trgm.
@@ -499,7 +518,7 @@ class EntityResolver:
unit_event_date: datetime | None,
taxonomy_lookup: set[str] | None = None,
labels_cfg=None,
) -> list[str]:
) -> list[ResolvedEntity]:
"""
Oracle strategy: fetch similar candidates using UTL_MATCH.JARO_WINKLER_SIMILARITY.
@@ -607,11 +626,14 @@ class EntityResolver:
cooccurrence_map: dict[str, set[str]],
taxonomy_lookup: set[str] | None = None,
labels_cfg=None,
) -> list[str]:
) -> list[ResolvedEntity]:
"""Shared scoring + upsert logic used by both lookup strategies."""
# Resolve each entity using pre-fetched candidates
entity_ids = [None] * len(entities_data)
# Resolve each entity using pre-fetched candidates. A slot stays None
# only if find-or-create fails to produce a row for a mention (a DB
# inconsistency); it surfaces as a clear error at the reassert boundary
# rather than a silent NOT NULL violation deeper in Phase 2.
resolved: list[ResolvedEntity | None] = [None] * len(entities_data)
entities_to_update: list[_EntityStat] = []
entities_to_create: list[_EntityToCreate] = []
@@ -638,21 +660,23 @@ class EntityResolver:
if is_label:
# Exact case-insensitive match only for label entities
exact_match = None
exact_match: ResolvedEntity | None = None
entity_text_lower = entity_text.lower()
for candidate_id, canonical_name, metadata, last_seen, mention_count in candidates:
if canonical_name.lower() == entity_text_lower:
exact_match = candidate_id
exact_match = ResolvedEntity(entity_id=candidate_id, canonical_name=canonical_name)
break
if exact_match:
entity_ids[idx] = exact_match
entities_to_update.append(_EntityStat(entity_id=exact_match, event_date=entity_event_date))
resolved[idx] = exact_match
entities_to_update.append(
_EntityStat(entity_id=exact_match.entity_id, event_date=entity_event_date)
)
else:
entities_to_create.append(_EntityToCreate(idx=idx, name=entity_text, event_date=entity_event_date))
continue
# Score candidates
best_candidate = None
best_candidate: ResolvedEntity | None = None
best_score = 0.0
nearby_entity_set = {e["text"].lower() for e in nearby_entities if e["text"] != entity_text}
@@ -685,14 +709,14 @@ class EntityResolver:
if score > best_score:
best_score = score
best_candidate = candidate_id
best_candidate = ResolvedEntity(entity_id=candidate_id, canonical_name=canonical_name)
# Apply unified threshold
threshold = 0.6
if best_score > threshold:
entity_ids[idx] = best_candidate
entities_to_update.append(_EntityStat(entity_id=best_candidate, event_date=entity_event_date))
if best_score > threshold and best_candidate is not None:
resolved[idx] = best_candidate
entities_to_update.append(_EntityStat(entity_id=best_candidate.entity_id, event_date=entity_event_date))
else:
entities_to_create.append(
_EntityToCreate(idx=idx, name=entity_data["text"], event_date=entity_event_date)
@@ -725,6 +749,9 @@ class EntityResolver:
sorted_groups = sorted(groups.items())
entity_names = [g.name for _, g in sorted_groups]
entity_dates = [g.event_date for _, g in sorted_groups]
# Stored canonical name per lowercase key, so a resurrected parent
# keeps the name it was created/matched with rather than a fallback.
canonical_by_name = {name_lower: g.name for name_lower, g in sorted_groups}
# INSERT ... ON CONFLICT DO NOTHING — no row lock on already-existing entities.
# mention_count starts at 0 here; flush_pending_stats() is the sole source of
@@ -759,11 +786,14 @@ class EntityResolver:
)
for row in existing_rows:
id_by_name[row["name_lower"]] = row["id"]
canonical_by_name[row["name_lower"]] = row["canonical_name"]
# Also index by Python's lower() of the original input name so the
# assignment loop (which uses Python-lowercased keys) finds it even
# when Python and the database produce different lowercase strings.
if "input_name" in row:
id_by_name[row["input_name"].lower()] = row["id"]
input_name_lower = row["input_name"].lower()
id_by_name[input_name_lower] = row["id"]
canonical_by_name[input_name_lower] = row["canonical_name"]
# Assign entity IDs back and queue one stat per original mention so that
# flush_pending_stats() increments mention_count by the true mention count,
@@ -771,16 +801,64 @@ class EntityResolver:
for name_lower, g in sorted_groups:
entity_id = id_by_name.get(name_lower)
if entity_id:
canonical_name = canonical_by_name.get(name_lower, g.name)
for original_idx in g.indices:
entity_ids[original_idx] = entity_id
pending.append(_EntityStat(entity_id=entity_id, event_date=g.event_date))
resolved[original_idx] = ResolvedEntity(entity_id=entity_id, canonical_name=canonical_name)
pending.append(_EntityStat(entity_id=str(entity_id), event_date=g.event_date))
# Accumulate into the resolver's pending list; the orchestrator flushes
# these with await entity_resolver.flush_pending_stats() after the txn.
key = self._task_key()
self._pending_stats.setdefault(key, []).extend(pending)
return entity_ids
missing = [i for i, entity in enumerate(resolved) if entity is None]
if missing:
raise RuntimeError(
f"Entity resolution produced no row for {len(missing)} mention(s) "
f"(indices {missing[:5]}); refusing to link units to a missing parent."
)
return cast(list[ResolvedEntity], resolved)
async def reassert_entities_batch(
self,
bank_id: str,
resolved_entities: list[ResolvedEntity],
conn,
) -> None:
"""Lock (and, if pruned, re-create) resolved parents before linking units.
Phase-1 resolution and the Phase-2 ``unit_entities`` insert run on
different transactions. In the gap, ``prune_orphan_entities`` can delete
a just-resolved parent — it legitimately has no ``unit_entities`` row
yet — and the Phase-2 FK insert then fails, dropping the whole batch as
non-retryable (silent memory loss, #2662).
Called on the Phase-2 connection immediately before
``link_units_to_entities_batch``, this locks the parents that still
exist (so the pruner blocks until we commit) and re-inserts any that
already vanished, in one round-trip. An entity referenced by a live unit
is by definition not an orphan, so resurrecting it is correct.
"""
# Deduplicate by id and lock in a stable order so concurrent reasserts
# acquire row locks consistently (same convention as bulk_insert_links).
seen: set[str] = set()
unique: list[ResolvedEntity] = []
for entity in sorted(resolved_entities, key=lambda e: e.entity_id):
if entity.entity_id in seen:
continue
seen.add(entity.entity_id)
unique.append(entity)
if not unique:
return
await self._ops.bulk_reassert_entities(
conn,
fq_table("entities"),
bank_id,
[entity.entity_id for entity in unique],
[entity.canonical_name for entity in unique],
)
async def link_units_to_entities_batch(
self,
@@ -853,20 +931,12 @@ class EntityResolver:
for unit_id, entity_ids in unit_to_entities.items():
entity_list = list(entity_ids)
event_date = unit_event_date.get(unit_id)
for i, entity_id_1 in enumerate(entity_list):
for entity_id_2 in entity_list[i + 1 :]:
if entity_id_1 == entity_id_2:
continue
# Canonical ordering (entity_id_1 < entity_id_2) matches the
# entity_cooccurrences PK and check constraint.
if entity_id_1 > entity_id_2:
entity_id_1, entity_id_2 = entity_id_2, entity_id_1
key = (entity_id_1, entity_id_2)
prev = cooccurrence_pairs.get(key, _SENTINEL_MISSING)
if prev is _SENTINEL_MISSING:
cooccurrence_pairs[key] = event_date
else:
cooccurrence_pairs[key] = _later_date(prev, event_date)
for key in _canonical_cooccurrence_pairs(entity_list):
prev = cooccurrence_pairs.get(key, _SENTINEL_MISSING)
if prev is _SENTINEL_MISSING:
cooccurrence_pairs[key] = event_date
else:
cooccurrence_pairs[key] = _later_date(prev, event_date)
# Accumulate co-occurrence pairs for post-transaction flush.
# The actual INSERT/UPDATE is deferred to flush_pending_stats() to avoid
@@ -39,6 +39,7 @@ import uuid as uuid_module
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from ..config import get_config
from ..models import RequestContext
from .db.base import DatabaseConnection
from .retain.link_utils import (
@@ -66,6 +67,20 @@ MAX_SEMANTIC_LINKS_PER_UNIT = 50
# under 1s.
_DRAIN_BATCH_SIZE = 50
# Retry budget for the idempotent Pass 2/3 entity/cooccurrence sweep. Higher
# than db_utils' default (3) because the sweep has no client waiting on it and
# is safe to rerun, so we'd rather spend a longer jittered-backoff tail than
# drop a maintenance pass and leak stale graph rows (see run_graph_maintenance_job).
_SWEEP_MAX_RETRIES = 8
@dataclass
class _SweepCounts:
"""Prune counts returned by the Pass 2/3 sweep (avoids a bare tuple return)."""
orphan_entities_pruned: int
stale_cooccurrences_pruned: int
@dataclass
class JobResult:
@@ -88,35 +103,48 @@ class JobResult:
async def enqueue_relink_victims(
conn: DatabaseConnection,
bank_id: str,
deleted_unit_ids: list[str],
affected_unit_ids: list[str],
ops: Any,
include_affected_units: bool = False,
) -> int:
"""Enqueue surviving units whose outgoing temporal/semantic links pointed at
``deleted_unit_ids`` for later link top-up.
``affected_unit_ids`` for later link top-up.
Must run inside the same transaction that deletes the units, *before* the
cascade fires once the rows are gone, the join that finds the victims
returns nothing.
Must run inside the same transaction that drops those links, *before* the
delete (or cascade) fires once the rows are gone, the join that finds the
victims returns nothing.
``include_affected_units`` covers the case where the affected units are NOT
being removed: an edit deletes every link incident to the edited unit but
leaves it live, so the unit needs its own outgoing adjacency rebuilt too.
Passing it for a unit that will be gone at commit is harmless but pointless
the drain skips queue rows with no live unit so callers should only set
it when the unit survives the transaction.
Args:
conn: Database connection inside the active delete transaction.
bank_id: Bank owning the deleted units.
deleted_unit_ids: Memory_unit IDs about to be (or being) deleted.
conn: Database connection inside the active transaction.
bank_id: Bank owning the affected units.
affected_unit_ids: Memory_unit IDs whose incident temporal/semantic
links are about to be (or are being) removed.
ops: ``DataAccessOps`` instance, supplies the dialect-specific
bulk-insert path.
include_affected_units: Also enqueue ``affected_unit_ids`` themselves,
for callers that leave them live. One combined insert (rather than a
second call) keeps the queue's sorted lock ordering intact: two
transactions editing mutually linked units would otherwise take the
``(bank_id, unit_id)`` keys in opposite orders and deadlock.
Returns:
Number of distinct victim units enqueued (after dedup against rows
already in the queue).
Number of distinct units passed to the queue insert.
"""
if not deleted_unit_ids:
if not affected_unit_ids:
return 0
deleted_uuids = [uuid_module.UUID(uid) if isinstance(uid, str) else uid for uid in deleted_unit_ids]
deleted_str_set = {str(uid) for uid in deleted_uuids}
affected_uuids = [uuid_module.UUID(uid) if isinstance(uid, str) else uid for uid in affected_unit_ids]
affected_str_set = {str(uid) for uid in affected_uuids}
# Find units (other than the ones being deleted) that have an outgoing
# temporal/semantic link pointing at a doomed unit. Entity links are
# Find units (other than the affected ones) that have an outgoing
# temporal/semantic link pointing at an affected unit. Entity links are
# intentionally excluded — they're scheduled for removal and would only
# add noise to the recompute job.
victim_rows = await conn.fetch(
@@ -127,27 +155,29 @@ async def enqueue_relink_victims(
AND bank_id = $2
AND link_type IN ('temporal', 'semantic')
""",
deleted_uuids,
affected_uuids,
bank_id,
)
victim_ids = [row["from_unit_id"] for row in victim_rows if str(row["from_unit_id"]) not in deleted_str_set]
relink_ids = {row["from_unit_id"] for row in victim_rows if str(row["from_unit_id"]) not in affected_str_set}
if include_affected_units:
relink_ids.update(affected_uuids)
if not victim_ids:
if not relink_ids:
return 0
await ops.enqueue_graph_maintenance(
conn,
fq_table("graph_maintenance_queue"),
bank_id,
victim_ids,
list(relink_ids),
)
logger.debug(
f"[GRAPH_MAINT] Enqueued {len(victim_ids)} relink victims in "
f"bank={bank_id} (deleted {len(deleted_unit_ids)} units)"
f"[GRAPH_MAINT] Enqueued {len(relink_ids)} units for relinking in "
f"bank={bank_id} ({len(affected_unit_ids)} units affected)"
)
return len(victim_ids)
return len(relink_ids)
async def run_graph_maintenance_job(
@@ -168,6 +198,7 @@ async def run_graph_maintenance_job(
result = JobResult()
job_start = time.time()
semantic_link_min_similarity = get_config().semantic_link_min_similarity
# --- Pass 1: relink ---
# Per-iteration loop: claim → top up → commit. We rely on submit-time
@@ -188,7 +219,14 @@ async def run_graph_maintenance_job(
if not unit_ids:
break
result.relink_links_added += await _relink_batch(conn, bank_id, unit_ids, ops, backend)
result.relink_links_added += await _relink_batch(
conn,
bank_id,
unit_ids,
ops,
backend,
semantic_link_min_similarity,
)
result.relink_units_processed += len(unit_ids)
iterations += 1
@@ -203,27 +241,51 @@ async def run_graph_maintenance_job(
# --- Pass 2 & 3: entity / cooccurrence sweeps ---
# Bank-wide single-statement deletes. Cheap when there's nothing to do.
#
# Unlike Pass 1's queue claim, these DELETEs aren't protected by any
# consistent lock-ordering guarantee: prune_stale_cooccurrences scans
# entity_cooccurrences via a join/NOT EXISTS plan, while retain's
# concurrent cooccurrence upserts (entity_resolver._flush_pending) lock
# the same rows in sorted (entity_id_1, entity_id_2) order. When a sweep
# and a concurrent upsert touch overlapping rows in opposite orders,
# Postgres detects a genuine circular wait and aborts one side with
# DeadlockDetectedError. Both prunes are idempotent bank-wide sweeps —
# rerunning only deletes what's still stale — so retrying the whole
# transaction on deadlock is safe.
from .db_utils import retry_with_backoff
from .memory_engine import acquire_with_retry
async with acquire_with_retry(backend) as conn:
async with conn.transaction():
result.orphan_entities_pruned = await ops.prune_orphan_entities(
conn,
fq_table("entities"),
fq_table("unit_entities"),
bank_id,
)
# The orphan prune above cascades cooccurrences via FK. The
# explicit cooccurrence pass below catches the *stale-count*
# case: both entities still exist but no current unit witnesses
# them together.
result.stale_cooccurrences_pruned = await ops.prune_stale_cooccurrences(
conn,
fq_table("entity_cooccurrences"),
fq_table("unit_entities"),
fq_table("entities"),
bank_id,
)
async def _run_sweep() -> _SweepCounts:
async with acquire_with_retry(backend) as conn:
async with conn.transaction():
orphan_pruned = await ops.prune_orphan_entities(
conn,
fq_table("entities"),
fq_table("unit_entities"),
bank_id,
)
# The orphan prune above cascades cooccurrences via FK. The
# explicit cooccurrence pass below catches the *stale-count*
# case: both entities still exist but no current unit
# witnesses them together.
stale_pruned = await ops.prune_stale_cooccurrences(
conn,
fq_table("entity_cooccurrences"),
fq_table("unit_entities"),
fq_table("entities"),
bank_id,
)
return _SweepCounts(orphan_entities_pruned=orphan_pruned, stale_cooccurrences_pruned=stale_pruned)
# A larger retry budget than the default (3): this is idempotent background
# maintenance with no client waiting on it, so a longer retry tail costs
# nothing, whereas a dropped sweep silently leaks orphan entities / stale
# cooccurrences until the next run. With jittered backoff a single sweep
# contending against continuous retain upserts effectively never exhausts
# this budget (each retry independently clears with high probability).
sweep = await retry_with_backoff(_run_sweep, max_retries=_SWEEP_MAX_RETRIES)
result.orphan_entities_pruned = sweep.orphan_entities_pruned
result.stale_cooccurrences_pruned = sweep.stale_cooccurrences_pruned
elapsed = time.time() - job_start
logger.info(
@@ -238,6 +300,7 @@ async def _relink_batch(
victim_ids: list[str],
ops: Any,
backend: Any,
semantic_link_min_similarity: float,
) -> int:
"""Top up temporal/semantic links for a batch of victim units. Returns rows inserted."""
# Load each victim's metadata. Victims whose units were deleted between
@@ -334,6 +397,7 @@ async def _relink_batch(
seed_ids,
seed_embs,
fact_types=seed_ftypes,
threshold=semantic_link_min_similarity,
)
# Strip self-links (rare but possible because the ANN probe
# has no exclude list — see the comment in compute_semantic_links_ann).
@@ -6,6 +6,7 @@ authentication when a TenantExtension is configured.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING, Any
@@ -13,9 +14,26 @@ if TYPE_CHECKING:
from hindsight_api.engine.memory_engine import BankLlmHealthInfo, Budget
from hindsight_api.engine.response_models import RecallResult, ReflectResult
from hindsight_api.engine.search.tags import TagsMatch
from hindsight_api.extensions import BankWriteOperation
from hindsight_api.models import RequestContext
@dataclass(frozen=True)
class BankConfigState:
"""Resolved bank configuration and its bank-level overrides."""
config: dict[str, Any]
overrides: dict[str, Any]
@dataclass(frozen=True)
class BankTemplateImportWrite:
"""One bank-write decision reserved for a specific imported resource."""
operation: "BankWriteOperation"
target: str | None = None
class MemoryEngineInterface(ABC):
"""
Abstract interface for the Memory Engine.
@@ -180,6 +198,37 @@ class MemoryEngineInterface(ABC):
"""
...
@abstractmethod
async def get_bank_config(
self,
bank_id: str,
*,
request_context: "RequestContext",
) -> BankConfigState:
"""Return resolved configuration after authenticating and authorizing the read."""
...
@abstractmethod
async def update_bank_config(
self,
bank_id: str,
updates: dict[str, Any],
*,
request_context: "RequestContext",
) -> BankConfigState:
"""Create a bank if needed and persist validated configuration overrides."""
...
@abstractmethod
async def reset_bank_config(
self,
bank_id: str,
*,
request_context: "RequestContext",
) -> BankConfigState:
"""Remove all bank configuration overrides after authorization."""
...
@abstractmethod
async def update_bank_disposition(
self,
@@ -275,6 +324,8 @@ class MemoryEngineInterface(ABC):
*,
fact_type: str | None = None,
search_query: str | None = None,
entity_id: str | None = None,
created_before: datetime | None = None,
limit: int = 100,
offset: int = 0,
request_context: "RequestContext",
@@ -286,6 +337,8 @@ class MemoryEngineInterface(ABC):
bank_id: The memory bank ID.
fact_type: Filter by fact type.
search_query: Full-text search query.
entity_id: Filter to memory units linked to this entity ID.
created_before: Keep units with ``created_at`` before this instant.
limit: Maximum results.
offset: Pagination offset.
request_context: Request context for authentication.
@@ -565,6 +618,30 @@ class MemoryEngineInterface(ABC):
"""
...
@abstractmethod
async def delete_operation(
self,
bank_id: str,
operation_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Delete a terminal async operation record.
Args:
bank_id: The memory bank ID.
operation_id: The operation ID to delete.
request_context: Request context for authentication.
Returns:
Dict with success status and message.
Raises:
ValueError: If operation not found.
"""
...
@abstractmethod
async def update_bank(
self,
@@ -572,6 +649,8 @@ class MemoryEngineInterface(ABC):
*,
name: str | None = None,
mission: str | None = None,
config_updates: dict[str, Any] | None = None,
create_if_missing: bool = True,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
@@ -581,6 +660,9 @@ class MemoryEngineInterface(ABC):
bank_id: The memory bank ID.
name: New bank name (optional).
mission: New mission text (optional, replaces existing).
config_updates: Bank configuration overrides to apply with the profile update.
create_if_missing: Create a missing bank when True; otherwise raise
a 404 operation error.
request_context: Request context for authentication.
Returns:
@@ -6,12 +6,53 @@ enabling support for multiple LLM backends (OpenAI, Anthropic, Gemini, Codex, et
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from enum import StrEnum
from typing import Any, Self
from .response_models import LLMToolCallResult
class LLMToolChoiceMode(StrEnum):
"""Canonical tool-selection modes shared by every LLM provider."""
AUTO = "auto"
NONE = "none"
REQUIRED = "required"
NAMED = "named"
@dataclass(frozen=True, slots=True)
class LLMToolChoice:
"""Typed internal tool selection serialized only at provider boundaries."""
mode: LLMToolChoiceMode
function_name: str | None = None
def __post_init__(self) -> None:
if self.mode is LLMToolChoiceMode.NAMED:
if self.function_name is None or not self.function_name or self.function_name != self.function_name.strip():
raise ValueError("Named tool choice requires a non-empty canonical function name")
elif self.function_name is not None:
raise ValueError(f"Tool choice mode {self.mode.value!r} cannot include a function name")
@classmethod
def named(cls, function_name: str) -> Self:
return cls(mode=LLMToolChoiceMode.NAMED, function_name=function_name)
@property
def selected_function_name(self) -> str:
if self.function_name is None:
raise ValueError("Tool choice does not select a named function")
return self.function_name
LLM_TOOL_CHOICE_AUTO = LLMToolChoice(mode=LLMToolChoiceMode.AUTO)
LLM_TOOL_CHOICE_NONE = LLMToolChoice(mode=LLMToolChoiceMode.NONE)
LLM_TOOL_CHOICE_REQUIRED = LLMToolChoice(mode=LLMToolChoiceMode.REQUIRED)
class LLMInterface(ABC):
"""
Abstract interface for LLM providers.
@@ -114,8 +155,9 @@ class LLMInterface(ABC):
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
cached_prefix: str | None = None,
cached_prefix_message_count: int = 0,
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support.
@@ -129,7 +171,7 @@ class LLMInterface(ABC):
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or specific function.
tool_choice: Canonical tool-selection policy.
Returns:
LLMToolCallResult with content and/or tool_calls.
@@ -185,6 +227,45 @@ class LLMInterface(ABC):
"""
return None
# ── Step-by-step incremental prompt caching (optional) ─────────────────────
#
# For agentic loops (reflect) the dominant cost is the conversation prefix
# re-sent every turn, not the static system prefix. Providers that can cache
# a *growing* prefix implement these: the caller rolls one cache per step
# (each covering the previous step's full input), passes its handle plus the
# message count it covers to ``call_with_tools`` so only the new turns are
# sent fresh, and tears the caches down when the loop ends. Default no-ops so
# non-supporting providers transparently run uncached.
def supports_incremental_prompt_cache(self) -> bool:
"""Whether this provider can cache a growing multi-turn conversation prefix."""
return False
async def create_incremental_cache(
self,
*,
session_id: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
) -> str | None:
"""Cache ``system + tools + messages`` and return an opaque handle, or None.
The handle is passed back to ``call_with_tools(cached_prefix=...,
cached_prefix_message_count=len(messages))``. Caches are grouped under
``session_id`` for teardown via ``delete_cache_session``. Returns None
when caching is unavailable or the prefix is too small caller falls
back to an uncached call.
"""
return None
async def delete_cached_prefix(self, name: str) -> None:
"""Best-effort delete of a single cache handle (a superseded step)."""
return None
async def delete_cache_session(self, session_id: str) -> None:
"""Best-effort teardown of every cache created under ``session_id``."""
return None
async def submit_batch(
self,
requests: list[dict[str, Any]],
@@ -36,6 +36,21 @@ from .db_utils import acquire_with_retry
logger = logging.getLogger(__name__)
def _llm_requests_persistable() -> bool:
"""Whether the ``llm_requests`` table exists on the active backend.
``llm_requests`` is PostgreSQL-only: its migration is ``run_for_dialect(pg=...)``
with the Oracle slot intentionally absent, and MaintenanceLoop skips its
retention sweep on Oracle for the same reason. On Oracle the table does not
exist, so best-effort trace writes must be skipped rather than attempted
otherwise every LLM call fires an INSERT that fails with ORA-00903 and spams
the error log. Mirrors the ``_is_oracle()`` gate in MaintenanceLoop.start.
"""
from .schema import _is_oracle
return not _is_oracle()
# ── bank/operation attribution (carried across the async call chain) ──────────
@@ -376,10 +391,32 @@ class LLMTraceRecorder:
# INSERTs it patches — but it must not block on unrelated operations).
self._pending: dict[str | None, set[asyncio.Task]] = {}
def _writable(self) -> Any | None:
"""Return the pool to write through, or None if writing isn't possible.
Covers the two lifecycle windows in which best-effort trace writes must
be skipped rather than attempted: before the backend pool is created
(``initialize()`` verifies the LLM before the DB is up) and during/after
shutdown. Writes already in flight need no handling the pools close
gracefully, waiting for their connections to be released.
"""
pool = self._pool_getter()
if pool is None:
return None
# Backends declare readiness explicitly; a raw pool (some callers pass
# one directly) has no lifecycle flag and is assumed usable.
from .db.base import DatabaseBackend
if isinstance(pool, DatabaseBackend) and not pool.is_ready:
return None
return pool
def is_enabled(self, scope: str) -> bool:
"""Whether tracing is active for the given call scope."""
if not self._enabled:
return False
if not _llm_requests_persistable():
return False
if self._allowed_scopes is not None:
return scope in self._allowed_scopes
return True
@@ -473,7 +510,7 @@ class LLMTraceRecorder:
async def _safe_write(self, record: LLMRequestRecord) -> None:
"""Write a trace row. Errors are logged, never raised."""
pool = self._pool_getter()
pool = self._writable()
if pool is None:
logger.debug("LLM trace skipped: pool not available")
return
@@ -546,7 +583,7 @@ class LLMTraceRecorder:
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:
if not self._enabled or not _llm_requests_persistable() 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]))
@@ -568,8 +605,9 @@ class LLMTraceRecorder:
# 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()
pool = self._writable()
if pool is None:
logger.debug("LLM trace memory_id attach skipped: pool not available")
return
try:
schema = self._schema_getter()
@@ -12,6 +12,8 @@ import uuid
from contextlib import AsyncExitStack
from typing import TYPE_CHECKING, Any
from json_repair import repair_json
# Vertex AI imports (conditional - for LLMProvider to pass credentials to GeminiLLM)
try:
from google.oauth2 import service_account
@@ -27,13 +29,11 @@ from ..config import (
ENV_REFLECT_LLM_MAX_CONCURRENT,
ENV_RETAIN_LLM_MAX_CONCURRENT,
)
from .llm_interface import LLM_TOOL_CHOICE_AUTO, LLMToolChoice, LLMToolChoiceMode
if TYPE_CHECKING:
from .response_models import LLMToolCallResult
# Seed applied to every Groq request for deterministic behavior.
DEFAULT_LLM_SEED = 4242
logger = logging.getLogger(__name__)
# Disable httpx logging
@@ -113,7 +113,7 @@ def _request_params(
temperature: float | None = None,
scope: str | None = None,
response_format: Any | None = None,
tool_choice: str | dict[str, Any] | None = None,
tool_choice: LLMToolChoice | None = None,
) -> dict[str, Any] | None:
"""Build the requested-params bag for tracing — only values the caller set.
@@ -128,8 +128,8 @@ def _request_params(
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"
if tool_choice is not None and tool_choice.mode is not LLMToolChoiceMode.AUTO:
params["tool_choice"] = tool_choice.function_name or tool_choice.mode.value
return params or None
@@ -184,6 +184,14 @@ def parse_llm_json(raw: str) -> Any:
1. Markdown code fences (```json ... ```) strip them before parsing.
2. Embedded control characters (\\x00-\\x1f, \\x7f) replace with space
and retry if the initial parse fails.
3. Structural malformation (trailing commas, unterminated strings, single
quotes, invalid ``\\escape`` sequences) repaired as a last resort via
``json_repair`` (#2547/#2544).
The repair pass is purely *structural*: it fixes JSON that ``json.loads``
cannot parse at all. It deliberately does NOT touch content semantics
degenerate-but-valid JSON (repetition loops or leaked scaffolding inside
string values) parses fine here and is out of scope for this helper.
Args:
raw: Raw text returned by the LLM.
@@ -192,7 +200,8 @@ def parse_llm_json(raw: str) -> Any:
Parsed Python object (dict, list, etc.).
Raises:
json.JSONDecodeError: If the text cannot be parsed even after cleanup.
json.JSONDecodeError: If the text cannot be parsed even after cleanup
and structural repair (e.g. repair yields an empty result).
"""
text = raw.strip()
@@ -209,7 +218,19 @@ def parse_llm_json(raw: str) -> Any:
# Some models (e.g. Gemini) embed raw control characters inside JSON
# string values. Replacing them with a space usually produces valid JSON.
cleaned = re.sub(r"[\x00-\x1f\x7f]", " ", text)
try:
return json.loads(cleaned)
except json.JSONDecodeError:
# Last resort: structural repair of malformed JSON. ``repair_json`` never
# raises — unrecoverable input yields an empty result ("" / {} / []). Keep
# failing loudly in that case rather than let an empty object masquerade
# as a successful parse: callers (retry ladders, the #1833 fail-loud path)
# rely on JSONDecodeError to retry or surface the failure.
repaired = repair_json(cleaned, return_objects=True)
if not repaired:
raise
return repaired
_PROVIDERS_WITHOUT_API_KEY = frozenset(
@@ -235,6 +256,17 @@ def requires_api_key(provider: str) -> bool:
return provider.lower() not in _PROVIDERS_WITHOUT_API_KEY
def _validate_ollama_num_ctx(value: Any) -> int | None:
"""Validate a native Ollama context-window override."""
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"ollama_num_ctx must be a positive integer, got {value!r}")
if value < 1:
raise ValueError(f"ollama_num_ctx must be >= 1, got {value}")
return value
def create_llm_provider(
provider: str,
api_key: str,
@@ -254,6 +286,7 @@ def create_llm_provider(
litellmrouter_config: dict[str, Any] | None = None,
gemini_service_tier: str | None = None,
timeout: float | None = None,
ollama_num_ctx: int | None = None,
) -> Any: # Returns LLMInterface
"""
Factory function to create the appropriate LLM provider implementation.
@@ -268,6 +301,8 @@ def create_llm_provider(
openai_service_tier: OpenAI service tier (for OpenAI provider) - None (default) or "flex" (50% cheaper).
bedrock_service_tier: Bedrock service tier (for Bedrock provider) - None (default), "flex", "priority", or "reserved".
gemini_service_tier: Gemini service tier (for Gemini provider) - None (default) or "flex" (50% cheaper).
ollama_num_ctx: Native Ollama context window override. None lets Ollama use the
model/server default.
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
@@ -291,6 +326,8 @@ def create_llm_provider(
Returns:
LLMInterface implementation for the specified provider.
"""
ollama_num_ctx = _validate_ollama_num_ctx(ollama_num_ctx)
from .providers import (
AnthropicLLM,
ClaudeCodeLLM,
@@ -494,6 +531,7 @@ def create_llm_provider(
groq_service_tier=groq_service_tier,
openai_service_tier=openai_service_tier,
extra_body=extra_body,
ollama_num_ctx=ollama_num_ctx,
timeout=timeout,
)
@@ -531,6 +569,7 @@ class LLMProvider:
max_retries: int | None = None,
initial_backoff: float | None = None,
max_backoff: float | None = None,
ollama_num_ctx: int | None = None,
):
"""
Initialize LLM provider.
@@ -545,6 +584,8 @@ class LLMProvider:
openai_service_tier: OpenAI service tier (None or "flex") - from config.
bedrock_service_tier: Bedrock service tier (None, "flex", "priority", "reserved") - from config.
gemini_service_tier: Gemini service tier (None or "flex") - from config.
ollama_num_ctx: Native Ollama context window override. ``None`` lets Ollama
use the model/server default.
gemini_safety_settings: Safety settings for Gemini/VertexAI providers.
extra_body: Extra request-body params merged into the provider's native call
(OpenAI-compatible, Fireworks, Anthropic, Gemini/VertexAI, LiteLLM).
@@ -598,6 +639,7 @@ class LLMProvider:
self.openai_service_tier = openai_service_tier
self.bedrock_service_tier = bedrock_service_tier
self.gemini_service_tier = gemini_service_tier
self.ollama_num_ctx = _validate_ollama_num_ctx(ollama_num_ctx)
# 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
@@ -742,6 +784,7 @@ class LLMProvider:
gemini_safety_settings=self.gemini_safety_settings,
prompt_cache_enabled=self.prompt_cache_enabled,
litellmrouter_config=router_config,
ollama_num_ctx=self.ollama_num_ctx,
timeout=self.timeout,
)
@@ -803,7 +846,7 @@ class LLMProvider:
initial_backoff: float | None = None,
max_backoff: float | None = None,
skip_validation: bool = False,
strict_schema: bool = False,
strict_schema: bool | None = None,
return_usage: bool = False,
cached_prefix: str | None = None,
) -> Any:
@@ -824,9 +867,10 @@ class LLMProvider:
configured default (``llm_max_backoff``), else 60.0.
skip_validation: Return raw JSON without Pydantic validation.
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.
structured output instead of the soft json_object path. None (the default)
inherits the server-level HINDSIGHT_API_LLM_STRICT_SCHEMA flag; an explicit
True or False wins over it, so a caller can force strict output on -- or off --
for its own scope. Providers without a strict mode ignore it.
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
Returns:
@@ -844,7 +888,13 @@ class LLMProvider:
from ..worker.stage import set_stage
structured = "+structured" if response_format is not None else ""
set_stage(f"llm.{self.provider}.{scope}{structured}")
# `.queued` until the concurrency permits are in hand — see the acquire
# below. Without it, a call waiting on a saturated semaphore is
# indistinguishable from one the provider is actively running, and the
# label points at the provider (#3002: an operator lost an hour to
# "llm.bedrock.*" for tasks that had never reached Bedrock).
base_stage = f"llm.{self.provider}.{scope}{structured}"
set_stage(f"{base_stage}.queued")
# Resolve the retry policy: explicit per-call arg wins, else the provider's
# configured per-operation/global default, else this method's own fallback.
@@ -861,14 +911,18 @@ class LLMProvider:
)
# 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,
# per-call argument, falling back to the server-level
# HINDSIGHT_API_LLM_STRICT_SCHEMA flag when the caller expressed no
# preference. 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
strict_schema = strict_schema or get_config().llm_strict_schema
# An explicit per-call value wins in BOTH directions -- `or` would have made a
# per-call False indistinguishable from "unset", silently ignoring any caller
# that opts out while the global flag is on.
strict_schema = strict_schema if strict_schema is not None else get_config().llm_strict_schema
# LLM call observability flows through the OTel GenAI recorder
# (tracing.get_span_recorder().record_llm_call). Provider implementations
@@ -900,6 +954,7 @@ class LLMProvider:
async with AsyncExitStack() as stack:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
set_stage(base_stage)
# cached_prefix is only set for providers that returned a handle
# from get_or_create_cached_prefix() (e.g. Gemini); it's None for
@@ -965,8 +1020,9 @@ class LLMProvider:
max_retries: int | None = None,
initial_backoff: float | None = None,
max_backoff: float | None = None,
tool_choice: str | dict[str, Any] = "auto",
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
cached_prefix: str | None = None,
cached_prefix_message_count: int = 0,
) -> "LLMToolCallResult":
"""
Make an LLM API call with tool/function calling support.
@@ -983,14 +1039,16 @@ class LLMProvider:
configured default (``llm_initial_backoff``), else 1.0.
max_backoff: Maximum backoff time in seconds. ``None`` uses the provider's
configured default (``llm_max_backoff``), else 30.0.
tool_choice: How to choose tools - "auto", "none", "required", or {"type": "function", "function": {"name": "..."}}
tool_choice: Canonical tool-selection policy.
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
from ..worker.stage import set_stage
set_stage(f"llm.{self.provider}.{scope}+tools")
# `.queued` until the permits are held — see the structured path above.
base_stage = f"llm.{self.provider}.{scope}+tools"
set_stage(f"{base_stage}.queued")
# Resolve the retry policy: explicit per-call arg wins, else the provider's
# configured per-operation/global default, else this method's own fallback.
@@ -1032,11 +1090,17 @@ class LLMProvider:
async with AsyncExitStack() as stack:
for sem in _semaphores_for_scope(scope):
await stack.enter_async_context(sem)
set_stage(base_stage)
# 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 {}
# from get_or_create_cached_prefix() / create_incremental_cache();
# forward it (plus how many leading messages it covers) only when
# present so non-caching providers keep their signature.
cache_kwarg = (
{"cached_prefix": cached_prefix, "cached_prefix_message_count": cached_prefix_message_count}
if cached_prefix is not None
else {}
)
try:
# Delegate to provider implementation
result = await self._provider_impl.call_with_tools(
@@ -1260,6 +1324,7 @@ class LLMProvider:
ENV_LLM_GROQ_SERVICE_TIER,
ENV_LLM_LITELLMROUTER_CONFIG,
ENV_LLM_MODEL,
ENV_LLM_OLLAMA_NUM_CTX,
ENV_LLM_OPENAI_SERVICE_TIER,
ENV_LLM_PROMPT_CACHE_ENABLED,
ENV_LLM_PROVIDER,
@@ -1270,6 +1335,7 @@ class LLMProvider:
ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY,
_get_default_model_for_provider,
_parse_llm_router_config,
_parse_optional_positive_int,
parse_gemini_service_tier,
)
@@ -1314,6 +1380,7 @@ class LLMProvider:
),
gemini_safety_settings=json.loads(os.getenv(ENV_LLM_GEMINI_SAFETY_SETTINGS, "null")),
prompt_cache_enabled=prompt_cache_enabled,
ollama_num_ctx=_parse_optional_positive_int(ENV_LLM_OLLAMA_NUM_CTX, os.getenv(ENV_LLM_OLLAMA_NUM_CTX)),
litellmrouter_config=_parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG),
vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or None,
vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION) or None,
@@ -0,0 +1,172 @@
"""Device selection and post-inference memory release for local (in-process)
SentenceTransformer / CrossEncoder models.
Two concerns live here, both about keeping a local API instance's memory flat:
**1. Device selection MPS is opt-in.**
On Apple Silicon the PyTorch **MPS** (Metal) backend caches a distinct compiled
kernel graph *and* allocator pool per unique input tensor shape, and never
releases them. Under the variable-length, high-volume recall/rerank/embed traffic
the engine generates (documents and candidate sets of every size), that per-shape
cache grows without bound. A local instance was observed idling at ~20 GB ~9.4 GB
of Metal graphics memory plus ~8 GB of native heap, essentially all of it stale
per-shape MPS cache. CPU inference has no per-shape cache: the same workload holds
flat at a few hundred MB, with negligible latency cost for the small default
models (and MPS actually *slows down* over time as it recompiles graphs for new
shapes). So MPS is excluded from auto-detection and must be opted into explicitly;
CUDA and Intel XPU still auto-select.
This is a confirmed, still-open PyTorch bug in the MPSGraph compilation cache
(keyed on tensor shape, no eviction path). We are tracking it upstream:
- https://github.com/pytorch/pytorch/issues/181213
([MPS] unbounded RSS growth with varying-shape inference our exact case)
- https://github.com/pytorch/pytorch/issues/164299 (graphCache identified as
the primary leak culprit)
- https://github.com/pytorch/pytorch/issues/182815 (proposes, but has not yet
shipped, a torch.mps.invalidate_graph_cache() API / PYTORCH_MPS_DISABLE_GRAPH_CACHE
env var that would let us keep MPS)
No released mitigation exists today: empty_cache(), synchronize(),
PYTORCH_MPS_HIGH_WATERMARK_RATIO, and autorelease pools were all confirmed
ineffective upstream. Revisit MPS-as-default once one of those knobs lands.
**2. Memory release after each batch.**
Local CPU inference allocates large transient numpy/tensor buffers per call. The
allocator keeps those freed pages as a high-water mark, so RSS grows monotonically
across many calls (issue #1717). We return them to the OS after each batch —
``malloc_trim`` on glibc/Linux, ``malloc_zone_pressure_relief`` on macOS (the
original #1717 fix covered only Linux). When the model ran on a GPU we also empty
that backend's allocator pool via ``torch.<backend>.empty_cache()``.
"""
from __future__ import annotations
import ctypes
import ctypes.util
import gc
import logging
import sys
logger = logging.getLogger(__name__)
def select_local_device(force_cpu: bool, allow_mps: bool) -> str | None:
"""Choose the device for a local SentenceTransformer / CrossEncoder.
Returns a value suitable to pass as the model's ``device`` argument:
- ``"cpu"`` forced CPU, or the only accelerator is MPS and it is not allowed.
- ``None`` let sentence-transformers auto-detect (picks CUDA / XPU,
handling multi-GPU correctly).
- ``"mps"`` Apple Silicon GPU, only when ``allow_mps`` is set.
MPS is never auto-selected because its per-shape cache leaks unbounded memory
under the engine's variable-length workload (see the module docstring). Set the
matching ``*_ALLOW_MPS`` config flag to opt back in.
"""
if force_cpu:
return "cpu"
try:
import torch
if torch.cuda.is_available():
return None # auto-detect CUDA
if hasattr(torch, "xpu") and torch.xpu.is_available():
return None # auto-detect Intel XPU
mps_available = hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
if mps_available:
if allow_mps:
return "mps"
logger.info(
"Local model: MPS (Apple Silicon GPU) is available but disabled by "
"default because its per-shape cache leaks memory under variable-length "
"workloads; running on CPU. Set the *_ALLOW_MPS flag to opt in."
)
return "cpu"
return "cpu"
except Exception as e: # pragma: no cover - defensive
logger.warning("Local device detection failed, falling back to CPU: %s", e)
return "cpu"
def resolve_model_device_type(model: object) -> str:
"""Best-effort device *type* ("cpu" / "cuda" / "mps" / "xpu") of a loaded model.
Used to decide which GPU allocator pool to empty after inference. Falls back to
``"cpu"`` (the safe no-op choice for release) if the device can't be read.
"""
device = getattr(model, "device", None)
if device is None:
inner = getattr(model, "model", None) # CrossEncoder wraps the HF model
device = getattr(inner, "device", None)
try:
return device.type if device is not None else "cpu"
except Exception: # pragma: no cover - defensive
return "cpu"
def _resolve_heap_trim():
"""Return a callable that asks the C allocator to release freed pages to the OS.
glibc (Linux) exposes ``malloc_trim``; macOS exposes
``malloc_zone_pressure_relief``. Resolved once at import; returns a no-op on
platforms where neither is available (musl, Windows).
"""
if sys.platform == "linux":
libc_path = ctypes.util.find_library("c")
if libc_path is None:
return lambda: None
try:
libc = ctypes.CDLL(libc_path)
trim = libc.malloc_trim
except (OSError, AttributeError):
# Not glibc (musl has no malloc_trim) or libc lookup failed.
return lambda: None
trim.argtypes = [ctypes.c_size_t]
trim.restype = ctypes.c_int
return lambda: trim(0)
if sys.platform == "darwin":
try:
libc = ctypes.CDLL("/usr/lib/libSystem.dylib")
default_zone = libc.malloc_default_zone
default_zone.restype = ctypes.c_void_p
relief = libc.malloc_zone_pressure_relief
relief.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
relief.restype = ctypes.c_size_t
except (OSError, AttributeError):
return lambda: None
# pressure_relief(zone, goal=0) reclaims as much as possible.
return lambda: relief(default_zone(), 0)
return lambda: None
_heap_trim = _resolve_heap_trim()
def _empty_gpu_cache(device_type: str | None) -> None:
"""Empty the allocator pool of the GPU backend the model ran on, if any."""
if not device_type or device_type == "cpu":
return
try:
import torch
backend = getattr(torch, device_type, None) # torch.cuda / torch.mps / torch.xpu
if backend is not None and hasattr(backend, "empty_cache"):
backend.empty_cache()
except Exception: # pragma: no cover - defensive
pass
def release_local_inference_memory(device_type: str | None = None) -> None:
"""Release transient heap (and GPU allocator) memory after a local inference batch.
Frees Python objects, returns freed native pages to the OS, and empties the GPU
allocator pool when the model ran on a GPU. Safe to call on every platform and
device; the pieces that don't apply are cheap no-ops.
"""
gc.collect()
_heap_trim()
_empty_gpu_cache(device_type)
@@ -21,9 +21,10 @@ from one place, so we don't spawn a separate ``asyncio`` task per concern:
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.
server-side PL/pgSQL routines (``schemas_with_expired_rows`` and
``banks_needing_consolidation``, in the configured schema see ``fq_routine``)
one round-trip each instead of a per-schema query storm, which matters at
thousands of tenants.
"""
from __future__ import annotations
@@ -32,13 +33,13 @@ import asyncio
import logging
import time
from collections.abc import Coroutine
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any
from ..config import HindsightConfig, get_config
from ..models import RequestContext
from .db_utils import acquire_with_retry
from .schema import _is_oracle, fq_table
from .schema import _is_oracle, fq_routine, fq_table, fq_table_explicit
if TYPE_CHECKING:
from .memory_engine import MemoryEngine
@@ -49,6 +50,10 @@ logger = logging.getLogger(__name__)
_TICK_SECONDS = 60
# Retention sweeps are not time-sensitive; hourly matches the previous per-sweep cadence.
_RETENTION_INTERVAL_SECONDS = 3600
# Operation cleanup deletes one bounded batch per schema per run, so its cadence
# sets the drain rate for a backlog. Kept at one-per-tick (the value it used while
# it rode the worker's poll loop) so throughput is unchanged by the move.
_OPERATION_CLEANUP_INTERVAL_SECONDS = 60
class MaintenanceLoop:
@@ -97,10 +102,14 @@ class MaintenanceLoop:
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
# Not gated on audit_log_enabled: that is per-bank overridable, so rows
# can exist even when the deployment default is off. Retention is driven
# purely by the (server-level) window.
audit_on = cfg.audit_log_retention_days > 0
llm_on = cfg.llm_trace_enabled and cfg.llm_trace_retention_days > 0
mm_refresh_on = cfg.mental_model_refresh_tick_seconds > 0
return reconcile_on or audit_on or llm_on or mm_refresh_on
op_cleanup_on = cfg.operation_retention_days > 0
return reconcile_on or audit_on or llm_on or mm_refresh_on or op_cleanup_on
# ── loop ───────────────────────────────────────────────────────────────
@@ -134,6 +143,8 @@ class MaintenanceLoop:
mm_interval = cfg.mental_model_refresh_tick_seconds
if mm_interval > 0 and self._is_due("mm_refresh", mm_interval):
await self._run_timed("scheduled mental model refresh", self._run_scheduled_mm_refresh())
if cfg.operation_retention_days > 0 and self._is_due("operation_cleanup", _OPERATION_CLEANUP_INTERVAL_SECONDS):
await self._run_timed("operation cleanup", self._run_operation_cleanup(cfg))
async def _run_timed(self, name: str, coro: Coroutine[Any, Any, None]) -> None:
"""Run a maintenance job and emit one timing line for it.
@@ -152,7 +163,10 @@ class MaintenanceLoop:
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:
# Not gated on audit_log_enabled: it is per-bank overridable, so a bank
# may be writing audit rows while the deployment default is off. Gating
# the purge on the global flag would let those rows accumulate forever.
if 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)
@@ -163,7 +177,7 @@ class MaintenanceLoop:
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
f"SELECT * FROM {fq_routine('schemas_with_expired_rows')}($1, $2, $3)", table, ts_col, days
)
for row in rows:
schema = row[0]
@@ -178,6 +192,73 @@ class MaintenanceLoop:
except Exception as e:
logger.warning(f"Retention sweep failed for {table}: {e}")
# ── terminal operation cleanup ─────────────────────────────────────────
async def _run_operation_cleanup(self, cfg: HindsightConfig) -> None:
"""Prune one bounded batch of expired terminal operations per tenant schema.
Previously this rode the worker's task-claiming loop, so it only fired
when that loop happened to iterate and was interleaved with claiming. It
is a periodic housekeeping sweep like the retention jobs above, so it
belongs on the same schedule.
Discovery is one cross-tenant round-trip (``schemas_with_expired_operations``)
rather than a connection + prune transaction per tenant; pending and
processing rows are never prunable, so a schema holding only in-flight
work is correctly reported as having nothing to do.
"""
engine = self._engine
backend = engine._backend
try:
async with acquire_with_retry(backend, max_retries=1) as conn:
rows = await conn.fetch(
f"SELECT * FROM {fq_routine('schemas_with_expired_operations')}($1)",
cfg.operation_retention_days,
)
except Exception as e:
logger.warning(f"Operation cleanup discovery failed: {e}")
return
if not rows:
return
# Prune only schemas the deployment actually serves. The routine reports
# every schema owning an async_operations table, including ones tenant
# discovery doesn't claim.
try:
tenants = await engine._tenant_extension.list_tenants()
except Exception as e:
logger.warning(f"Operation cleanup tenant discovery failed: {e}")
return
known = {t.schema for t in tenants} | {get_config().database_schema}
from .memory_engine import _current_schema
cutoff = datetime.now(timezone.utc) - timedelta(days=cfg.operation_retention_days)
pruned = 0
for row in rows:
schema = row[0]
if schema not in known:
continue
# Oracle resolves unqualified names from a context-bound session
# schema; on PostgreSQL this is harmless and fq_table stays explicit.
token = _current_schema.set(schema)
try:
table = fq_table_explicit("async_operations", schema)
async with acquire_with_retry(backend, max_retries=1) as conn:
async with conn.transaction():
deleted = await backend.ops.prune_terminal_operations(
conn, table, cutoff, batch_size=cfg.operation_cleanup_batch_size
)
if deleted:
pruned += deleted
logger.info(f"Operation cleanup pruned {deleted} expired terminal operations from {schema}")
except Exception as e:
logger.warning(f"Operation cleanup failed for schema {schema}: {e}")
finally:
_current_schema.reset(token)
if pruned:
logger.info(f"Operation cleanup: pruned {pruned} operation(s) total")
# ── consolidation reconcile ──────────────────────────────────────────────
async def _run_reconcile(self) -> None:
@@ -185,7 +266,9 @@ class MaintenanceLoop:
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()")
rows = await conn.fetch(
f"SELECT schema_name, bank_id FROM {fq_routine('banks_needing_consolidation')}()"
)
except Exception as e:
logger.warning(f"Consolidation reconcile discovery failed: {e}")
return
@@ -244,7 +327,7 @@ class MaintenanceLoop:
Discovery (the set of cron-scheduled models, minus any with an in-flight
refresh) is one cross-tenant round-trip via
``public.mental_models_with_cron()``. Cron *due-ness* is evaluated here in
``mental_models_with_cron()``. Cron *due-ness* is evaluated here in
Python a scheduled fire has elapsed when the most recent cron boundary at
or before now is later than ``last_refreshed_at`` because cron arithmetic
isn't expressible in plain SQL. Each due model is refreshed only when it is
@@ -256,7 +339,7 @@ class MaintenanceLoop:
async with acquire_with_retry(engine._backend, max_retries=1) as conn:
rows = await conn.fetch(
"SELECT schema_name, bank_id, mental_model_id, refresh_cron, last_refreshed_at "
"FROM public.mental_models_with_cron()"
f"FROM {fq_routine('mental_models_with_cron')}()"
)
except Exception as e:
logger.warning(f"Scheduled mental model refresh discovery failed: {e}")
File diff suppressed because it is too large Load Diff
@@ -142,3 +142,21 @@ class RefreshMentalModelMetadata:
def to_dict(self) -> dict[str, Any]:
"""Convert to dict for JSON serialization."""
return asdict(self)
@dataclass
class RefreshMentalModelOutcomeMetadata:
"""Machine-readable outcome metadata for a completed refresh_mental_model operation.
Refresh parity with RetainOutcomeMetadata (#2605): lets a monitoring layer
distinguish "refreshed with real content" from "refreshed empty" by reading
result_metadata alone, without a follow-up content fetch.
"""
content_len: int
populated_content: bool
based_on_counts: dict[str, int] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
"""Convert to dict for JSON serialization."""
return asdict(self)
@@ -186,7 +186,11 @@ class MarkitdownParser(FileParser):
if Path(filename).suffix.lower() not in _TEXT_EXTENSIONS:
return None
try:
file_data.decode("utf-8")
# file_data may arrive as a non-``bytes`` buffer (e.g. a memoryview or
# a native/Rust-backed buffer object) that has no ``.decode``; coerce
# through the buffer protocol before the UTF-8 probe. The ``tmp.write``
# in the caller already relies only on the same buffer protocol.
bytes(file_data).decode("utf-8")
except UnicodeDecodeError:
return None
from markitdown import StreamInfo
@@ -14,8 +14,9 @@ import logging
import time
from typing import Any
from hindsight_api.engine.llm_interface import LLMInterface
from hindsight_api.engine.llm_interface import LLM_TOOL_CHOICE_AUTO, LLMInterface, LLMToolChoice
from hindsight_api.engine.llm_trace import LLMResponseUsage, stash_response_usage
from hindsight_api.engine.providers.llm_debug import dump_request_on_4xx
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
@@ -34,6 +35,43 @@ def _usage_from_anthropic_response(response: Any) -> LLMResponseUsage:
)
_EPHEMERAL_CACHE = {"type": "ephemeral"}
def _cached_system_blocks(system_prompt: str) -> list[dict[str, Any]]:
"""Render the system prompt as a block list with a cache_control marker.
Anthropic prompt caching is a prefix match: marking the (single) system
block caches tools + system together. The system prompt is stable per
scope fact extraction reuses it across every chunk, reflect and
consolidation keep their stable instructions there so repeat calls read
it at ~10% of the base input price. Markers below the model's minimum
cacheable prefix are silently ignored (no write premium), so marking is
safe unconditionally. This is the "inline-marker provider" strategy that
``LLMInterface.get_or_create_cached_prefix`` documents for Anthropic.
"""
return [{"type": "text", "text": system_prompt, "cache_control": _EPHEMERAL_CACHE}]
def _mark_last_message_for_caching(messages: list[dict[str, Any]]) -> None:
"""Add a cache_control marker to the final content block, in place.
Used on the multi-turn (tool-calling) path: the reflect agent loop resends
the entire growing conversation each iteration, so this request's
end-marker becomes the next iteration's cache read point. Together with
the system marker this uses 2 of the 4 allowed breakpoints.
"""
if not messages:
return
last = messages[-1]
content = last.get("content")
if isinstance(content, str):
if content.strip(): # the API rejects empty text blocks
last["content"] = [{"type": "text", "text": content, "cache_control": _EPHEMERAL_CACHE}]
elif isinstance(content, list) and content and isinstance(content[-1], dict):
content[-1]["cache_control"] = _EPHEMERAL_CACHE
class AnthropicLLM(LLMInterface):
"""
LLM provider using Anthropic's Claude models.
@@ -206,7 +244,9 @@ class AnthropicLLM(LLMInterface):
}
if system_prompt:
call_params["system"] = system_prompt
# One-shot calls share only the system prompt with each other, so
# that is the sole cache breakpoint on this path.
call_params["system"] = _cached_system_blocks(system_prompt)
if use_forced_tool:
# Single tool whose input_schema IS the response schema; force the model to
@@ -346,6 +386,9 @@ class AnthropicLLM(LLMInterface):
logger.error(f"Anthropic auth error (HTTP {e.status_code}), not retrying: {str(e)}")
raise
# Diagnostic dump (opt-in) of the exact request behind any 4xx.
dump_request_on_4xx(scope=scope, provider=self.provider, model=self.model, err=e, request=call_params)
last_exception = e
if attempt < max_retries:
# Check if it's a rate limit or server error
@@ -380,7 +423,7 @@ class AnthropicLLM(LLMInterface):
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support.
@@ -450,6 +493,11 @@ class AnthropicLLM(LLMInterface):
else:
anthropic_messages.append({"role": role, "content": content})
# Multi-turn tool loop: cache the stable prefix (tools + system) via
# the system marker, and the growing conversation via an end-marker
# that the next iteration reads back.
_mark_last_message_for_caching(anthropic_messages)
call_params: dict[str, Any] = {
"model": self.model,
"messages": anthropic_messages,
@@ -457,7 +505,7 @@ class AnthropicLLM(LLMInterface):
"max_tokens": max_completion_tokens or 4096,
}
if system_prompt:
call_params["system"] = system_prompt
call_params["system"] = _cached_system_blocks(system_prompt)
if self._extra_body:
call_params["extra_body"] = self._extra_body
@@ -533,6 +581,8 @@ class AnthropicLLM(LLMInterface):
except (APIConnectionError, APIStatusError) as e:
if isinstance(e, APIStatusError) and e.status_code in (401, 403):
raise
# Diagnostic dump (opt-in) of the exact request behind any 4xx.
dump_request_on_4xx(scope=scope, provider=self.provider, model=self.model, err=e, request=call_params)
last_exception = e
if attempt < max_retries:
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
@@ -543,6 +593,217 @@ class AnthropicLLM(LLMInterface):
raise last_exception
raise RuntimeError("Anthropic tool call failed")
# ── Message Batches API (50% token discount) ─────────────────────────────
_BATCH_TOOL_NAME = "structured_response"
async def supports_batch_api(self) -> bool:
"""Anthropic supports batch operations via the Message Batches API."""
return True
@staticmethod
def _map_batch_status(processing_status: str) -> str:
"""Map Anthropic ``processing_status`` onto the OpenAI vocabulary.
The engine's poll loop breaks on "completed" and hard-fails on
"failed"/"expired"/"cancelled"; anything else keeps polling. Anthropic
batches only end as "ended" (per-request failures surface in the
results, mirroring OpenAI's "completed"-with-errors semantics), so
"ended" maps to "completed" and the non-terminal states pass through.
"""
return "completed" if processing_status == "ended" else processing_status
def _translate_batch_body(self, body: dict[str, Any]) -> dict[str, Any]:
"""Translate one OpenAI-shaped request body into Messages API params.
Mirrors the conversion rules of ``call()``: system messages fold into
the ``system`` param; ``max_completion_tokens`` becomes ``max_tokens``
(default 4096); ``temperature`` is dropped (the sync path never sends
it either current Claude models reject non-default sampling params);
an OpenAI ``response_format`` json_schema becomes a single forced
tool_use tool when strict (native constrained decoding, issue #1002),
else the schema is injected into the system prompt.
The system prompt carries the same cache_control marker as the sync
one-shot path (its sole breakpoint): every request in a retain batch
shares the fact-extraction system prompt, so the first item's cache
write serves the remaining items as best-effort reads and the
cache-read discount stacks with the 50% batch discount.
"""
system_prompt: str | None = None
messages: list[dict[str, Any]] = []
for msg in body.get("messages", []):
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_prompt = (system_prompt + "\n\n" + content) if system_prompt else content
else:
messages.append({"role": role, "content": content})
params: dict[str, Any] = {
"model": body.get("model") or self.model,
"messages": messages,
"max_tokens": body.get("max_completion_tokens") or 4096,
}
json_schema = (body.get("response_format") or {}).get("json_schema") or {}
schema = json_schema.get("schema")
if schema is not None:
if json_schema.get("strict"):
params["tools"] = [
{
"name": self._BATCH_TOOL_NAME,
"description": "Return the structured response.",
"input_schema": schema,
}
]
params["tool_choice"] = {"type": "tool", "name": self._BATCH_TOOL_NAME}
else:
schema_msg = "\n\nYou must respond with valid JSON matching this schema:\n" + json.dumps(
schema, indent=2, ensure_ascii=False
)
system_prompt = (system_prompt + schema_msg) if system_prompt else schema_msg
if system_prompt:
params["system"] = _cached_system_blocks(system_prompt)
# Batch params ARE the raw Messages body, so operator-configured extra
# body params merge directly (the sync path routes them through the
# SDK's extra_body, which does the same merge server-side).
if self._extra_body:
params.update(self._extra_body)
return params
def _translate_batch_message(self, message: Any) -> dict[str, Any]:
"""Render an Anthropic Message as the OpenAI response body the engine parses.
The engine reads ``choices[0].message.content`` (json.loads'ing it when
a schema was requested) and sums ``usage`` under the OpenAI key names.
Forced-tool responses carry their JSON in the tool_use block's input,
so that is re-serialized as the content string.
"""
content = ""
tool_input = None
for block in message.content:
if block.type == "tool_use" and block.name == self._BATCH_TOOL_NAME:
tool_input = block.input or {}
elif block.type == "text":
content += block.text
if tool_input is not None:
content = json.dumps(tool_input, ensure_ascii=False)
usage = getattr(message, "usage", None)
input_tokens = (usage.input_tokens or 0) if usage else 0
output_tokens = (usage.output_tokens or 0) if usage else 0
return {
"choices": [
{
"message": {"role": "assistant", "content": content},
"finish_reason": getattr(message, "stop_reason", None),
}
],
"usage": {
"prompt_tokens": input_tokens,
"completion_tokens": output_tokens,
"total_tokens": input_tokens + output_tokens,
},
}
async def submit_batch(
self,
requests: list[dict[str, Any]],
endpoint: str = "/v1/chat/completions",
completion_window: str = "24h",
) -> dict[str, Any]:
"""Submit a batch of requests to the Message Batches API.
Accepts the engine's OpenAI-JSONL-shaped entries. ``endpoint`` and
``completion_window`` belong to that shared shape and have no Anthropic
equivalent (batches always resolve within 24 hours); both are ignored.
"""
batch_requests = [
{
"custom_id": req["custom_id"],
"params": self._translate_batch_body(req.get("body") or {}),
}
for req in requests
]
logger.info(f"Submitting Anthropic message batch with {len(batch_requests)} requests")
batch = await self._client.messages.batches.create(requests=batch_requests)
logger.info(f"Anthropic batch submitted: {batch.id}, status={batch.processing_status}")
return {
"batch_id": batch.id,
"status": self._map_batch_status(batch.processing_status),
"created_at": batch.created_at,
"request_count": len(batch_requests),
}
async def get_batch_status(self, batch_id: str) -> dict[str, Any]:
"""Get batch status in the shape the engine's poll loop expects."""
batch = await self._client.messages.batches.retrieve(batch_id)
counts = batch.request_counts
processing = getattr(counts, "processing", 0) or 0
succeeded = getattr(counts, "succeeded", 0) or 0
errored = getattr(counts, "errored", 0) or 0
canceled = getattr(counts, "canceled", 0) or 0
expired = getattr(counts, "expired", 0) or 0
resolved = succeeded + errored + canceled + expired
result: dict[str, Any] = {
"batch_id": batch.id,
"status": self._map_batch_status(batch.processing_status),
"created_at": batch.created_at,
"request_counts": {
"total": processing + resolved,
"completed": resolved,
"failed": errored,
},
}
ended_at = getattr(batch, "ended_at", None)
if ended_at:
result["completed_at"] = ended_at
return result
async def retrieve_batch_results(self, batch_id: str) -> list[dict[str, Any]]:
"""Retrieve completed batch results, translated to the OpenAI shape.
Succeeded entries become ``{"custom_id", "response": {"body": ...}}``;
errored/canceled/expired entries become ``{"custom_id", "error": ...}``
so the engine's per-result error handling applies unchanged.
"""
batch = await self._client.messages.batches.retrieve(batch_id)
if batch.processing_status != "ended":
raise ValueError(f"Batch {batch_id} is not completed yet (status: {batch.processing_status})")
decoder = await self._client.messages.batches.results(batch_id)
results: list[dict[str, Any]] = []
async for entry in decoder:
outcome = entry.result
if outcome.type == "succeeded":
results.append(
{
"custom_id": entry.custom_id,
"response": {"body": self._translate_batch_message(outcome.message)},
}
)
else:
error = getattr(outcome, "error", None)
if error is not None:
detail = f"{getattr(error, 'type', 'error')}: {getattr(error, 'message', error)}"
else:
detail = f"batch request {outcome.type}"
results.append({"custom_id": entry.custom_id, "error": detail})
logger.info(f"Retrieved {len(results)} results for Anthropic batch {batch_id}")
return results
async def cleanup(self) -> None:
"""Clean up resources (close Anthropic client connections)."""
if hasattr(self, "_client") and self._client:
@@ -15,7 +15,7 @@ from typing import Any
from pydantic import ValidationError
from hindsight_api.engine.llm_interface import LLMInterface
from hindsight_api.engine.llm_interface import LLM_TOOL_CHOICE_AUTO, LLMInterface, LLMToolChoice, LLMToolChoiceMode
from hindsight_api.engine.llm_trace import LLMResponseUsage, stash_response_usage
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
@@ -49,6 +49,20 @@ def _get_isolated_claude_env() -> dict[str, str]:
return _isolated_claude_env
def _result_error_detail(message: Any) -> str:
"""Build an actionable error string from an ``is_error`` ResultMessage.
The CLI can report a failure with ``is_error=True`` while ``subtype``
still reads ``"success"``, putting the real detail in ``result`` (e.g.
quota exhaustion: ``You've hit your weekly limit · resets ...`` with
``api_error_status: 429``). The SDK's own fallback exception surfaces
only the subtype, producing the misleading "Claude Code returned an
error result: success" (issue #2702) — so prefer ``result``.
"""
detail = (message.result or "").strip() or message.subtype or "unknown error"
return f"Claude Code reported an error: {detail}"
class ClaudeCodeLLM(LLMInterface):
"""
LLM provider using Claude Code authentication.
@@ -176,6 +190,7 @@ class ClaudeCodeLLM(LLMInterface):
from claude_agent_sdk import ( # type: ignore[unresolved-import]
AssistantMessage,
ClaudeAgentOptions,
ResultMessage,
TextBlock,
query,
)
@@ -209,10 +224,27 @@ class ClaudeCodeLLM(LLMInterface):
user_content += schema_instruction
# Configure SDK options
#
# tools=[] is required here for the same reason call_with_tools() below
# already sets it: with `tools` left at its default (None -> full
# "claude_code" built-in preset), allowed_tools=[] alone does not stop
# the CLI from loading the full built-in toolset and deferring into
# ToolSearch before answering, which burns the single max_turns=1
# budget on a tool-deferral step instead of a text response. Without
# this, single-turn calls intermittently fail with "Reached maximum
# number of turns (1)" even though the prompt itself needs no tools.
options = ClaudeAgentOptions(
system_prompt=system_prompt if system_prompt else None,
max_turns=1, # Single-turn for API-style interactions
tools=[], # Disable built-in tools so nothing forces a ToolSearch deferral
allowed_tools=[], # Disable tools for standard LLM calls
# Pin the configured model (issue #2881). Without this the spawned CLI
# runs its own default model — an Opus-class model on Pro/Max OAuth —
# regardless of HINDSIGHT_API_*_LLM_MODEL, while metrics/logs still print
# self.model, so the mismatch is invisible. The isolated CLAUDE_CONFIG_DIR
# (fresh temp dir) means a host settings.json can't reach the CLI either,
# so passing it through here is the only channel.
model=self.model or None,
env=_get_isolated_claude_env(),
)
@@ -228,6 +260,11 @@ class ClaudeCodeLLM(LLMInterface):
for block in message.content:
if isinstance(block, TextBlock):
full_text += block.text
elif isinstance(message, ResultMessage) and message.is_error:
# Surface the CLI's actual error text (e.g. quota
# exhaustion) instead of the SDK's subtype-based
# fallback exception (issue #2702).
raise RuntimeError(_result_error_detail(message))
# The Claude Agent SDK doesn't report exact counts; stash the same
# char/4 estimate the success path traces so a later parse/validate
@@ -362,7 +399,7 @@ class ClaudeCodeLLM(LLMInterface):
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support using Claude Agent SDK.
@@ -380,7 +417,7 @@ class ClaudeCodeLLM(LLMInterface):
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or specific function dict.
tool_choice: Canonical tool-selection policy.
- "auto": Model decides whether to call tools (default)
- "required": Model must call at least one tool
- "none": Model must not call any tools
@@ -393,6 +430,7 @@ class ClaudeCodeLLM(LLMInterface):
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
ResultMessage,
SdkMcpTool,
TextBlock,
ToolUseBlock,
@@ -473,30 +511,27 @@ class ClaudeCodeLLM(LLMInterface):
mcp_servers_config = {"hindsight_tools": mcp_server} if sdk_tools else {}
# Process tool_choice
if isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
if tool_choice.mode is LLMToolChoiceMode.NAMED:
# Force a specific tool: filter allowed_tools to only that tool and add instruction
forced_name = tool_choice.get("function", {}).get("name")
if forced_name:
# Filter to only the forced tool (with MCP prefix)
forced_tool_mcp_name = f"mcp__hindsight_tools__{forced_name}"
if forced_tool_mcp_name in allowed_tool_names:
allowed_tool_names = [forced_tool_mcp_name]
# Add strong instruction to system prompt
force_instruction = (
f"\n\nIMPORTANT: You MUST call the '{forced_name}' tool. Do not respond with text only."
)
system_prompt += force_instruction
logger.debug(f"Claude Code: Forcing tool call to '{forced_name}'")
else:
logger.warning(f"Claude Code: Forced tool '{forced_name}' not found in available tools")
elif tool_choice == "required":
forced_name = tool_choice.selected_function_name
forced_tool_mcp_name = f"mcp__hindsight_tools__{forced_name}"
if forced_tool_mcp_name in allowed_tool_names:
allowed_tool_names = [forced_tool_mcp_name]
force_instruction = (
f"\n\nIMPORTANT: You MUST call the '{forced_name}' tool. Do not respond with text only."
)
system_prompt += force_instruction
logger.debug(f"Claude Code: Forcing tool call to '{forced_name}'")
else:
logger.warning(f"Claude Code: Forced tool '{forced_name}' not found in available tools")
elif tool_choice.mode is LLMToolChoiceMode.REQUIRED:
# Must call at least one tool
tool_instruction = (
"\n\nIMPORTANT: You MUST call at least one of the available tools. Do not respond with text only."
)
system_prompt += tool_instruction
logger.debug("Claude Code: Tool call required")
elif tool_choice == "none":
elif tool_choice.mode is LLMToolChoiceMode.NONE:
# No tools should be called - disable all tools
allowed_tool_names = []
mcp_servers_config = {}
@@ -504,16 +539,33 @@ class ClaudeCodeLLM(LLMInterface):
# else: tool_choice == "auto" or unspecified - use default behavior (no changes needed)
# Configure SDK options with MCP server
#
# tools=[] disables built-in CLI tools (Read, Write, Bash, ToolSearch, etc.)
# Without this, Claude Code CLI defers MCP tools when too many built-in tools
# are loaded, forcing Claude to use ToolSearch first — which wastes the max_turns
# are loaded, forcing Claude to use ToolSearch first — which wastes the turn
# budget and prevents direct MCP tool calls.
#
# max_turns=1 is critical (issue #2966). call_with_tools() is one *round* of
# an agentic loop the caller drives: the model proposes tool calls, we return
# them, and the orchestrator (reflect/agent.py) executes the REAL tools and
# feeds the results back on the next call. The SDK, however, runs its own
# in-process loop: it invokes our SDK MCP handlers — which are deliberate
# placeholders returning "[Tool <name> called successfully]" (no real data) —
# and lets the model react. With max_turns >= 2 the model calls recall, sees
# the empty placeholder, re-queries with reworded searches, exhausts the turn
# budget, and the run ends in error_max_turns with its tool calls discarded —
# exactly the "0 tool calls / no information" failure in #2966. Capping at a
# single turn stops the SDK from acting on the placeholder results: the model
# emits its first tool call (or a text answer) and we return that to the caller
# unchanged, matching how every other provider's call_with_tools() behaves.
options = ClaudeAgentOptions(
system_prompt=system_prompt if system_prompt else None,
tools=[], # Disable built-in tools so MCP tools load eagerly
max_turns=2, # Allow tool call + tool result round-trip
max_turns=1, # One round: propose tool calls (or answer); caller drives the loop
mcp_servers=mcp_servers_config,
allowed_tools=allowed_tool_names,
# Pin the configured model (issue #2881) — see the call() options block.
model=self.model or None,
env=_get_isolated_claude_env(),
)
@@ -550,6 +602,21 @@ class ClaudeCodeLLM(LLMInterface):
arguments=block.input,
)
)
if tool_calls:
# This round proposed tool call(s). Stop consuming the
# stream so the SDK does not run another turn against our
# placeholder handlers (issue #2966) — the caller executes
# the real tools and calls us again with the results.
break
elif isinstance(message, ResultMessage) and message.is_error:
# With max_turns=1 the CLI reports error_max_turns whenever
# the model spent its single turn issuing a tool call (there
# was no follow-up turn to emit final text). That is expected
# here and not a failure: we already captured the tool call
# above and break before reaching this branch. Only a genuine
# error with nothing to return should surface (issue #2702).
if not tool_calls:
raise RuntimeError(_result_error_detail(message))
# Record metrics
duration = time.time() - start_time
@@ -19,6 +19,7 @@ from __future__ import annotations
import base64
import binascii
import contextlib
import json
import logging
import os
@@ -31,6 +32,11 @@ from typing import Any
import httpx
try:
import fcntl
except ImportError: # pragma: no cover - Windows
fcntl = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
@@ -58,6 +64,9 @@ _CODEX_TOKEN_REFRESH_SKEW_SECONDS = 60
_CODEX_TERMINAL_REFRESH_ERROR_CODES = frozenset(
{"refresh_token_expired", "refresh_token_reused", "refresh_token_invalidated"}
)
_CODEX_AUTH_LOCK_TIMEOUT_SECONDS = 20.0
_CODEX_AUTH_LOCKS_GUARD = threading.Lock()
_CODEX_AUTH_LOCKS: dict[Path, threading.Lock] = {}
def default_codex_auth_file() -> Path:
@@ -76,6 +85,44 @@ def default_codex_auth_file() -> Path:
return Path.home() / ".codex" / "auth.json"
def _path_scoped_lock(auth_file: Path) -> threading.Lock:
key = auth_file.expanduser().resolve(strict=False)
with _CODEX_AUTH_LOCKS_GUARD:
lock = _CODEX_AUTH_LOCKS.get(key)
if lock is None:
lock = threading.Lock()
_CODEX_AUTH_LOCKS[key] = lock
return lock
@contextlib.contextmanager
def _codex_auth_lock(auth_file: Path, timeout_seconds: float = _CODEX_AUTH_LOCK_TIMEOUT_SECONDS):
"""Cross-process advisory lock for one Codex auth store."""
with _path_scoped_lock(auth_file):
if fcntl is None: # pragma: no cover - Windows
logger.debug("fcntl unavailable; Codex refresh proceeds without a cross-process lock.")
yield
return
lock_path = auth_file.with_suffix(".lock")
lock_path.parent.mkdir(parents=True, exist_ok=True)
with open(lock_path, "a+") as lock_file:
deadline = time.monotonic() + max(1.0, timeout_seconds)
while True:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except (BlockingIOError, OSError):
if time.monotonic() >= deadline:
raise TimeoutError("Timed out waiting for the Codex auth store lock") from None
time.sleep(0.05)
try:
yield
finally:
with contextlib.suppress(OSError):
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
class CodexRefreshExpiredError(RuntimeError):
"""Raised when the Codex refresh_token itself is no longer valid.
@@ -192,6 +239,34 @@ class CodexAuthManager:
return None
return data.get("tokens", {}).get("refresh_token")
@staticmethod
def _load_tokens_from_file(auth_file: Path) -> dict[str, Any] | None:
try:
with open(auth_file) as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return None
tokens = data.get("tokens")
return tokens if isinstance(tokens, dict) else None
def _adopt_tokens(self, tokens: dict[str, Any]) -> bool:
"""Adopt a newer on-disk Codex token set if present."""
access_token = tokens.get("access_token")
refresh_token = tokens.get("refresh_token")
account_id = tokens.get("account_id")
changed = False
if isinstance(access_token, str) and access_token and access_token != self.access_token:
self.access_token = access_token
changed = True
if isinstance(refresh_token, str) and refresh_token and refresh_token != self.refresh_token:
self.refresh_token = refresh_token
changed = True
if isinstance(account_id, str) and account_id and account_id != self.account_id:
self.account_id = account_id
changed = True
return changed
@staticmethod
def _decode_jwt_exp_unixtime(token: str) -> int | None:
"""Return the JWT ``exp`` claim as a unix timestamp, or None on parse failure.
@@ -225,6 +300,11 @@ class CodexAuthManager:
return False
return exp <= int(time.time()) + skew_seconds
def _token_is_fresh_with_known_expiry(self, skew_seconds: int = _CODEX_TOKEN_REFRESH_SKEW_SECONDS) -> bool:
"""True only when the cached token has a known expiry outside the skew window."""
exp = self._decode_jwt_exp_unixtime(self.access_token)
return exp is not None and exp > int(time.time()) + skew_seconds
# ------------------------------------------------------------------
# Persistence
# ------------------------------------------------------------------
@@ -339,78 +419,93 @@ class CodexAuthManager:
if not self._token_is_stale():
return
if not self.refresh_token:
raise RuntimeError(
"Codex access_token is expired but no refresh_token is available. "
"Run 'codex auth login' to re-authenticate."
)
with _codex_auth_lock(self._auth_file):
disk_tokens = self._load_tokens_from_file(self._auth_file)
if disk_tokens and self._adopt_tokens(disk_tokens):
if force or self._token_is_fresh_with_known_expiry():
return
log_reason = f" ({reason})" if reason else ""
logger.info(f"Refreshing Codex OAuth access_token{log_reason}")
request_body = {
"client_id": _CODEX_CLIENT_ID,
"grant_type": "refresh_token",
"refresh_token": self.refresh_token,
}
try:
response = self._http_client.post(
_CODEX_REFRESH_TOKEN_URL,
json=request_body,
headers={"Content-Type": "application/json"},
timeout=30.0,
)
except httpx.RequestError as e:
raise RuntimeError(f"Codex OAuth refresh network error: {type(e).__name__}") from e
if response.status_code == 401:
error_code = self._extract_oauth_error_code(response)
if error_code in _CODEX_TERMINAL_REFRESH_ERROR_CODES:
raise CodexRefreshExpiredError(
f"Codex refresh_token is permanently invalid (error.code={error_code}). "
if not self.refresh_token:
raise RuntimeError(
"Codex access_token is expired but no refresh_token is available. "
"Run 'codex auth login' to re-authenticate."
)
raise CodexRefreshExpiredError(
f"Codex OAuth refresh returned 401 with unrecognized error code "
f"({error_code or 'none'}). Run 'codex auth login' to re-authenticate."
)
if response.status_code >= 400:
raise RuntimeError(f"Codex OAuth refresh failed with HTTP {response.status_code}")
log_reason = f" ({reason})" if reason else ""
logger.info(f"Refreshing Codex OAuth access_token{log_reason}")
try:
body = response.json()
except json.JSONDecodeError as e:
raise RuntimeError(f"Codex OAuth refresh returned non-JSON body: {e}") from e
request_access_token = self.access_token
request_refresh_token = self.refresh_token
request_body = {
"client_id": _CODEX_CLIENT_ID,
"grant_type": "refresh_token",
"refresh_token": request_refresh_token,
}
try:
response = self._http_client.post(
_CODEX_REFRESH_TOKEN_URL,
json=request_body,
headers={"Content-Type": "application/json"},
timeout=30.0,
)
except httpx.RequestError as e:
raise RuntimeError(f"Codex OAuth refresh network error: {type(e).__name__}") from e
new_access = body.get("access_token")
if not new_access:
raise RuntimeError("Codex OAuth refresh returned no access_token")
if response.status_code == 401:
error_code = self._extract_oauth_error_code(response)
disk_tokens = self._load_tokens_from_file(self._auth_file)
if disk_tokens and (
disk_tokens.get("access_token") != request_access_token
or disk_tokens.get("refresh_token") != request_refresh_token
):
self._adopt_tokens(disk_tokens)
return
if error_code in _CODEX_TERMINAL_REFRESH_ERROR_CODES:
raise CodexRefreshExpiredError(
f"Codex refresh_token is permanently invalid (error.code={error_code}). "
"Run 'codex auth login' to re-authenticate."
)
raise CodexRefreshExpiredError(
f"Codex OAuth refresh returned 401 with unrecognized error code "
f"({error_code or 'none'}). Run 'codex auth login' to re-authenticate."
)
new_refresh = body.get("refresh_token") or self.refresh_token
new_id_token = body.get("id_token")
if response.status_code >= 400:
raise RuntimeError(f"Codex OAuth refresh failed with HTTP {response.status_code}")
# Update in-memory state first so waiters see fresh credentials
# immediately, even if disk write fails.
self.access_token = new_access
self.refresh_token = new_refresh
try:
body = response.json()
except json.JSONDecodeError as e:
raise RuntimeError(f"Codex OAuth refresh returned non-JSON body: {e}") from e
persisted: dict[str, Any] = {
"access_token": new_access,
"refresh_token": new_refresh,
}
if new_id_token:
persisted["id_token"] = new_id_token
new_access = body.get("access_token")
if not new_access:
raise RuntimeError("Codex OAuth refresh returned no access_token")
try:
self._persist_auth_atomic(persisted)
except OSError as e:
logger.warning(
f"Codex OAuth refresh succeeded but persisting auth.json failed: {type(e).__name__}. "
"In-memory credentials are up to date; on-disk file is stale."
)
new_refresh = body.get("refresh_token") or self.refresh_token
new_id_token = body.get("id_token")
logger.info("Codex OAuth access_token refreshed successfully")
# Update in-memory state first so waiters see fresh credentials
# immediately, even if disk write fails.
self.access_token = new_access
self.refresh_token = new_refresh
persisted: dict[str, Any] = {
"access_token": new_access,
"refresh_token": new_refresh,
}
if new_id_token:
persisted["id_token"] = new_id_token
try:
self._persist_auth_atomic(persisted)
except OSError as e:
logger.warning(
f"Codex OAuth refresh succeeded but persisting auth.json failed: {type(e).__name__}. "
"In-memory credentials are up to date; on-disk file is stale."
)
logger.info("Codex OAuth access_token refreshed successfully")
def ensure_fresh_token(self) -> None:
"""Proactively refresh the access_token if it is near or past expiry.
@@ -25,9 +25,11 @@ from typing import Any
import httpx
from hindsight_api.engine.llm_interface import LLMInterface
from hindsight_api.engine.llm_interface import LLM_TOOL_CHOICE_AUTO, LLMInterface, LLMToolChoice, LLMToolChoiceMode
from hindsight_api.engine.llm_trace import LLMResponseUsage, stash_response_usage
from hindsight_api.engine.providers.llm_debug import dump_request_on_4xx
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.engine.structured_output import strict_json_schema
from hindsight_api.metrics import get_metrics_collector
from .codex_auth import (
@@ -53,6 +55,59 @@ __all__ = [
logger = logging.getLogger(__name__)
# Newer Codex models are gated on the first-party client identity; the previous
# browser-shaped User-Agent returned "Model not found" for Luna (#2643).
# Use a neutral version because Hindsight must not claim a specific Codex release.
_CODEX_ORIGINATOR = "codex_cli_rs"
_CODEX_USER_AGENT = "codex_cli_rs/0.0.0 (Hindsight)"
# Name of the single forced function tool used to carry structured output when
# strict_schema is on. The Codex backend speaks the OpenAI Responses API, so a
# forced function call gives us constrained decoding straight into the response
# schema — no prompt-injected schema, no raw json.loads on free-form model text,
# no invalid-\escape retry storm (issue #2504, same class as #1002 / #2339).
_STRUCTURED_TOOL_NAME = "structured_response"
# Valid JSON string escape characters (the char that may follow a backslash).
_VALID_JSON_ESCAPE_CHARS = set('"\\/bfnrtu')
def _repair_invalid_json_escapes(text: str) -> str:
"""Best-effort repair of invalid ``\\escape`` sequences in a JSON string.
Escape-heavy content (code, serial/CLI commands, Windows paths, regexes)
makes weaker models emit backslashes that aren't valid JSON escapes (e.g.
``\\d``, ``\\s``, ``C:\\Users``), so ``json.loads`` fails deterministically
and every retry re-fails the same way (issue #2504). This doubles any
backslash that isn't part of a valid escape so the payload parses. It is a
lenient fallback only the strict_schema forced-tool path is the real fix.
"""
result: list[str] = []
i = 0
n = len(text)
while i < n:
ch = text[i]
if ch == "\\" and i + 1 < n:
nxt = text[i + 1]
if nxt in _VALID_JSON_ESCAPE_CHARS:
# Preserve the valid escape (both chars) verbatim.
result.append(ch)
result.append(nxt)
i += 2
continue
# Invalid escape: escape the lone backslash so JSON parses.
result.append("\\\\")
i += 1
continue
if ch == "\\" and i + 1 == n:
# Trailing lone backslash — escape it.
result.append("\\\\")
i += 1
continue
result.append(ch)
i += 1
return "".join(result)
class CodexLLM(LLMInterface):
"""
@@ -117,8 +172,8 @@ class CodexLLM(LLMInterface):
if self.model.startswith("openai/"):
self.model = self.model[len("openai/") :]
# Map reasoning effort to Codex reasoning summary format
# Codex supports: "auto", "concise", "detailed"
# Reasoning summary controls presentation separately from the backend's
# reasoning effort, which is sent unchanged in each request payload.
self.reasoning_summary = self._map_reasoning_effort(reasoning_effort)
# HTTP client for SSE streaming
@@ -140,6 +195,18 @@ class CodexLLM(LLMInterface):
def account_id(self) -> str:
return self._auth_manager.account_id
def _build_request_headers(self) -> httpx.Headers:
return httpx.Headers(
{
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
"OpenAI-Account-ID": self.account_id,
"User-Agent": _CODEX_USER_AGENT,
"Origin": "https://chatgpt.com",
"originator": _CODEX_ORIGINATOR,
}
)
@property
def refresh_token(self) -> str | None:
return self._auth_manager.refresh_token
@@ -276,32 +343,6 @@ class CodexLLM(LLMInterface):
}
return mapping.get(effort.lower(), "auto")
def _normalize_tool_choice(self, tool_choice: str | dict[str, Any]) -> str | dict[str, Any]:
"""Normalize forced function tool choice for the Codex Responses API.
Older agent paths may still pass OpenAI chat-completions style named
tool choice payloads such as:
{"type": "function", "function": {"name": "recall"}}
Codex Responses expects the named function at the top level instead:
{"type": "function", "name": "recall"}
"""
if not isinstance(tool_choice, dict):
return tool_choice
if str(tool_choice.get("type") or "").strip() != "function":
return tool_choice
function_payload = tool_choice.get("function")
if isinstance(function_payload, dict):
function_name = str(function_payload.get("name") or "").strip()
if function_name:
return {"type": "function", "name": function_name}
function_name = str(tool_choice.get("name") or "").strip()
if function_name:
return {"type": "function", "name": function_name}
return tool_choice
async def verify_connection(self) -> None:
"""Verify Codex connection by making a simple test call."""
try:
@@ -336,7 +377,18 @@ class CodexLLM(LLMInterface):
strict_schema: bool = False,
return_usage: bool = False,
) -> Any:
"""Make API call to Codex backend with SSE streaming."""
"""Make API call to Codex backend with SSE streaming.
Args:
strict_schema: Route structured output through a single forced
function tool (constrained decoding) instead of prompt-injecting
the schema and parsing free-form text. The Codex backend speaks
the OpenAI Responses API, so the forced function call emits the
response schema directly as tool arguments eliminating the
invalid-``\\escape`` retry storm (issue #2504). When False, falls
back to schema-in-prompt + JSON parse, now hardened with a lenient
invalid-escape repair before giving up.
"""
start_time = time.time()
# Proactively refresh the OAuth access_token if it's near expiry.
@@ -361,11 +413,22 @@ class CodexLLM(LLMInterface):
else:
user_messages.append(msg)
# Add JSON schema instruction if response_format is provided
# Structured output: prefer a single forced function tool (constrained
# decoding) over text-injecting the schema and parsing the reply. The
# forced tool guarantees schema-shaped JSON in the tool arguments,
# eliminating the invalid-\escape retry storm (issue #2504). When
# strict_schema is off we keep the schema-in-prompt + json.loads
# fallback (now hardened with a lenient escape repair) for callers that
# can't force tools.
schema = None
use_forced_tool = False
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)}"
system_instruction += schema_msg
schema = strict_json_schema(response_format) if strict_schema else response_format.model_json_schema()
if strict_schema:
use_forced_tool = True
else:
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
system_instruction += schema_msg
# gpt-5.2-codex only supports "detailed" reasoning summary
reasoning_summary = "detailed" if "5.2" in self.model else self.reasoning_summary
@@ -385,20 +448,28 @@ class CodexLLM(LLMInterface):
"tools": [],
"tool_choice": "auto",
"parallel_tool_calls": True,
"reasoning": {"summary": reasoning_summary},
"reasoning": {"effort": self.reasoning_effort, "summary": reasoning_summary},
"store": False, # Codex uses stateless mode
"stream": True, # SSE streaming
"include": ["reasoning.encrypted_content"],
"prompt_cache_key": str(uuid.uuid4()),
}
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
"OpenAI-Account-ID": self.account_id,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"Origin": "https://chatgpt.com",
}
if use_forced_tool and schema is not None:
# Single function tool whose parameters ARE the response schema;
# force it via tool_choice so the backend does constrained decoding.
payload["tools"] = [
{
"type": "function",
"name": _STRUCTURED_TOOL_NAME,
"description": "Return the structured response.",
"parameters": schema,
}
]
payload["tool_choice"] = {"type": "function", "name": _STRUCTURED_TOOL_NAME}
payload["parallel_tool_calls"] = False
headers = self._build_request_headers()
url = f"{self.base_url}/codex/responses"
@@ -412,8 +483,15 @@ class CodexLLM(LLMInterface):
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
response.raise_for_status()
# Parse SSE stream
content = await self._parse_sse_stream(response)
# Forced-tool path: read structured output from the function-call
# arguments (already a JSON string in a dedicated channel) rather
# than from free-form assistant text.
if use_forced_tool:
text_content, tool_calls = await self._parse_sse_tool_stream(response)
content = text_content or ""
else:
tool_calls = []
content = await self._parse_sse_stream(response)
# Codex SSE carries no usage block; stash the same char/4 estimate
# the success path traces so a later parse/validate failure records
@@ -426,7 +504,28 @@ class CodexLLM(LLMInterface):
)
# Handle structured output
if response_format is not None:
if use_forced_tool:
tool_input = None
for tc in tool_calls:
if tc.name == _STRUCTURED_TOOL_NAME:
tool_input = tc.arguments if isinstance(tc.arguments, dict) else None
break
if tool_input is None:
# Model ignored the forced tool (rare — e.g. a gateway that
# drops tool_choice). Retry so we don't hard-fail.
logger.warning(
f"Codex forced structured tool missing from response "
f"(attempt {attempt + 1}/{max_retries + 1})"
)
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
attempt += 1
continue
raise RuntimeError("Codex did not return the forced structured_response tool call")
content = json.dumps(tool_input)
result = tool_input if skip_validation else response_format.model_validate(tool_input)
elif response_format is not None:
# Models may wrap JSON in markdown
clean_content = content
if "```json" in content:
@@ -437,13 +536,20 @@ class CodexLLM(LLMInterface):
try:
json_data = json.loads(clean_content)
except json.JSONDecodeError as e:
logger.warning(f"Codex JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {e}")
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
attempt += 1
continue
raise
# Escape-heavy content deterministically re-fails every
# retry (issue #2504). Try a lenient invalid-escape repair
# before burning a retry / re-raising.
try:
json_data = json.loads(_repair_invalid_json_escapes(clean_content))
logger.info("Codex JSON parsed after repairing invalid escape sequences")
except json.JSONDecodeError:
logger.warning(f"Codex JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {e}")
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
attempt += 1
continue
raise
if skip_validation:
result = json_data
@@ -542,6 +648,9 @@ class CodexLLM(LLMInterface):
"Run 'codex auth login' to re-authenticate."
) from e
# Diagnostic dump (opt-in) of the exact request behind any 4xx.
dump_request_on_4xx(scope=scope, provider=self.provider, model=self.model, err=e, request=payload)
# Log the actual error message from the API
error_detail = e.response.text[:500] if hasattr(e.response, "text") else str(e)
@@ -637,7 +746,7 @@ class CodexLLM(LLMInterface):
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
) -> LLMToolCallResult:
"""
Make API call with tool calling support.
@@ -654,7 +763,7 @@ class CodexLLM(LLMInterface):
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or a specific function.
tool_choice: Canonical tool-selection policy.
Returns:
LLMToolCallResult with content and/or tool_calls.
@@ -716,22 +825,20 @@ class CodexLLM(LLMInterface):
"instructions": system_instruction,
"input": user_messages,
"tools": codex_tools,
"tool_choice": self._normalize_tool_choice(tool_choice),
"tool_choice": (
{"type": "function", "name": tool_choice.selected_function_name}
if tool_choice.mode is LLMToolChoiceMode.NAMED
else tool_choice.mode.value
),
"parallel_tool_calls": True,
"reasoning": {"summary": reasoning_summary},
"reasoning": {"effort": self.reasoning_effort, "summary": reasoning_summary},
"store": False,
"stream": True,
"include": ["reasoning.encrypted_content"],
"prompt_cache_key": str(uuid.uuid4()),
}
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
"OpenAI-Account-ID": self.account_id,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"Origin": "https://chatgpt.com",
}
headers = self._build_request_headers()
url = f"{self.base_url}/codex/responses"
@@ -828,6 +935,8 @@ class CodexLLM(LLMInterface):
)
except Exception as e:
# Diagnostic dump (opt-in) of the exact request behind any 4xx.
dump_request_on_4xx(scope=scope, provider=self.provider, model=self.model, err=e, request=payload)
logger.error(f"Codex tool call error: {e}")
raise
@@ -872,8 +981,13 @@ class CodexLLM(LLMInterface):
try:
arguments = json.loads(arguments_str)
except json.JSONDecodeError:
logger.warning(f"Failed to parse tool arguments: {arguments_str}")
arguments = {}
# Escape-heavy content can emit invalid \escape
# sequences (issue #2504); repair before giving up.
try:
arguments = json.loads(_repair_invalid_json_escapes(arguments_str))
except json.JSONDecodeError:
logger.warning(f"Failed to parse tool arguments: {arguments_str}")
arguments = {}
tool_calls.append(
LLMToolCall(
@@ -56,6 +56,14 @@ _DEFAULT_REFRESH_MARGIN_SECONDS = 5 * 60
# to None and callers proceed uncached, rather than stalling the whole batch.
_DEFAULT_CREATE_TIMEOUT_SECONDS = 30.0
# TTL for the per-step reflect caches created by ``create_incremental``. These
# live only for the duration of one reflect (seconds), so the TTL is just a
# storage backstop in case the explicit ``delete_session`` at reflect end is
# missed (crash / event-loop teardown). Short so orphaned caches age out fast —
# storage is billed per token-hour, so a 5-minute cap keeps the cost of a leaked
# cache negligible.
_DEFAULT_INCREMENTAL_TTL_SECONDS = 5 * 60
@dataclass
class _CacheEntry:
@@ -92,6 +100,10 @@ class GeminiCacheManager:
self._create_timeout_seconds = create_timeout_seconds
self._entries: dict[str, _CacheEntry] = {}
self._lock = asyncio.Lock()
# session_id -> CachedContent names created via ``create_incremental``.
# A reflect creates a fresh rolling cache per step under one session id;
# ``delete_session`` tears them all down when the reflect finishes.
self._sessions: dict[str, list[str]] = {}
@staticmethod
def fingerprint(
@@ -229,18 +241,99 @@ class GeminiCacheManager:
if entry.name == name:
self._entries.pop(key, None)
async def create_incremental(
self,
*,
session_id: str,
model: str,
system_instruction: str,
contents: list[Any],
tools: list[dict[str, Any]] | None = None,
) -> str | None:
"""Create a fresh CachedContent holding ``system + tools + contents`` and
track it under ``session_id`` for later teardown.
Unlike ``get_or_create``, this does NOT deduplicate by fingerprint: each
step of a reflect grows the conversation prefix, so every call is a
distinct, single-use cache. The reflect loop creates one per step (each
covering the previous step's full input) and reuses it for exactly the
next model turn, then supersedes it. All caches for the session are
deleted by ``delete_session`` when the reflect ends; the short TTL is
only a backstop.
Returns the cache resource name, or ``None`` when caching is disabled,
the prefix is below the model minimum, or the create otherwise fails
callers MUST fall back to an uncached call in that case.
"""
try:
name = await self._create_cache(
model=model,
system_instruction=system_instruction,
tools=tools,
contents=contents,
ttl_seconds=_DEFAULT_INCREMENTAL_TTL_SECONDS,
)
except _CacheNotEligible as e:
logger.debug(
"GeminiCacheManager: incremental prefix not eligible (model=%s, reason=%s) — caller falls back",
model,
e,
)
return None
except Exception:
logger.exception(
"GeminiCacheManager: failed to create incremental cache (model=%s); caller falls back",
model,
)
return None
if name is not None:
self._sessions.setdefault(session_id, []).append(name)
return name
async def delete(self, name: str) -> None:
"""Best-effort server-side delete of a single CachedContent.
Swallows all errors: a failed delete just means the cache ages out on
its TTL. Also drops any matching in-process entry.
"""
self.invalidate(name)
try:
await self._client.aio.caches.delete(name=name)
except Exception:
logger.debug("GeminiCacheManager: delete of cache %s failed (will age out on TTL)", name, exc_info=True)
async def delete_session(self, session_id: str) -> None:
"""Delete every CachedContent created for ``session_id`` (reflect teardown).
Deletes concurrently and best-effort a reflect must never fail because
a cache couldn't be torn down; the short TTL is the backstop.
"""
names = self._sessions.pop(session_id, [])
if not names:
return
await asyncio.gather(*(self.delete(n) for n in names), return_exceptions=True)
async def _create_cache(
self,
*,
model: str,
system_instruction: str,
tools: list[dict[str, Any]] | None = None,
contents: list[Any] | None = None,
ttl_seconds: int | 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``.
``contents`` (already-converted ``genai_types.Content`` turns) is
appended after the system_instruction/tools so the cache can hold a
growing multi-turn conversation prefix, not just the static prefix
this is what the step-by-step reflect cache relies on. ``ttl_seconds``
overrides the manager default (used to give per-step reflect caches a
short backstop TTL).
"""
# Lazy import so this module doesn't require the SDK at import time.
from google.genai import types as genai_types
@@ -254,8 +347,10 @@ class GeminiCacheManager:
# 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",
"ttl": f"{ttl_seconds if ttl_seconds is not None else self._ttl_seconds}s",
}
if contents:
config_kwargs["contents"] = contents
if tools:
# OpenAI-style {"function": {...}} entries must be converted to
# Gemini's Tool/FunctionDeclaration shape before caching.
@@ -13,15 +13,17 @@ import json
import logging
import time
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Any
from google import genai
from google.genai import errors as genai_errors
from google.genai import types as genai_types
from hindsight_api.engine.llm_interface import LLMInterface
from hindsight_api.engine.llm_interface import LLM_TOOL_CHOICE_AUTO, LLMInterface, LLMToolChoice, LLMToolChoiceMode
from hindsight_api.engine.llm_trace import LLMResponseUsage, stash_response_usage
from hindsight_api.engine.llm_wrapper import parse_llm_json
from hindsight_api.engine.providers.llm_debug import dump_request_on_4xx
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
from hindsight_api.worker.stage import set_stage
@@ -63,6 +65,99 @@ def _usage_from_gemini_response(response: Any) -> LLMResponseUsage:
)
@dataclass(frozen=True)
class _GeminiConversation:
"""A message list converted to Gemini's request shape."""
system_instruction: str | None
contents: list["genai_types.Content"]
def _convert_messages_to_gemini(msg_list: list[dict[str, Any]]) -> _GeminiConversation:
"""Convert OpenAI-style messages to a Gemini (system_instruction, contents) pair.
Shared by ``call_with_tools`` (request body) and the incremental cache
builder so a cached prefix and the live request serialise turns identically
any drift would fingerprint differently and defeat the cache. Consecutive
``role="tool"`` messages are grouped into a single ``user`` Content with
multiple FunctionResponse parts, matching Gemini's multi-turn requirement.
"""
system_instruction: str | None = None
gemini_contents: list[genai_types.Content] = []
pending_tool_names_by_call_id: dict[str, str] = {}
i = 0
while i < len(msg_list):
msg = msg_list[i]
role = msg.get("role", "user")
content = msg.get("content", "")
if role != "tool" and pending_tool_names_by_call_id:
missing_ids = ", ".join(sorted(pending_tool_names_by_call_id))
raise ValueError(f"Gemini assistant tool calls require results before the next message: {missing_ids}")
if role == "system":
system_instruction = (system_instruction + "\n\n" + content) if system_instruction else content
i += 1
elif role == "tool":
parts = []
while i < len(msg_list) and msg_list[i].get("role") == "tool":
tool_msg = msg_list[i]
tool_content = tool_msg.get("content", "")
tool_call_id = tool_msg["tool_call_id"]
tool_name = pending_tool_names_by_call_id.pop(tool_call_id, None)
if tool_name is None:
raise ValueError(f"Gemini tool result references unknown tool_call_id {tool_call_id!r}")
parts.append(
genai_types.Part(
function_response=genai_types.FunctionResponse(
name=tool_name,
response={"result": tool_content},
)
)
)
i += 1
if pending_tool_names_by_call_id:
missing_ids = ", ".join(sorted(pending_tool_names_by_call_id))
raise ValueError(f"Gemini assistant tool calls are missing results: {missing_ids}")
gemini_contents.append(genai_types.Content(role="user", parts=parts))
elif role == "assistant":
tool_calls_in_msg = msg.get("tool_calls", [])
if tool_calls_in_msg:
parts = []
if content:
parts.append(genai_types.Part(text=content))
for tc in tool_calls_in_msg:
tool_call_id = tc["id"]
fn = tc["function"]
fn_name = fn["name"]
if tool_call_id in pending_tool_names_by_call_id:
raise ValueError(
f"Gemini assistant tool call id {tool_call_id!r} must be unique within its turn"
)
pending_tool_names_by_call_id[tool_call_id] = fn_name
fn_args_str = fn.get("arguments", "{}")
fn_args = parse_llm_json(fn_args_str)
thought_signature = tc.get("thought_signature")
fc_kwargs: dict[str, Any] = {"name": fn_name, "args": fn_args}
part_kwargs: dict[str, Any] = {"function_call": genai_types.FunctionCall(**fc_kwargs)}
if thought_signature:
part_kwargs["thought_signature"] = base64.b64decode(thought_signature)
parts.append(genai_types.Part(**part_kwargs))
gemini_contents.append(genai_types.Content(role="model", parts=parts))
else:
gemini_contents.append(genai_types.Content(role="model", parts=[genai_types.Part(text=content)]))
i += 1
else:
gemini_contents.append(genai_types.Content(role="user", parts=[genai_types.Part(text=content)]))
i += 1
if pending_tool_names_by_call_id:
missing_ids = ", ".join(sorted(pending_tool_names_by_call_id))
raise ValueError(f"Gemini assistant tool calls are missing results: {missing_ids}")
return _GeminiConversation(system_instruction=system_instruction, contents=gemini_contents)
class GeminiLLM(LLMInterface):
"""
LLM provider for Google Gemini and Vertex AI.
@@ -329,8 +424,7 @@ class GeminiLLM(LLMInterface):
last_exception = None
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.gemini.{scope}.attempt={attempt + 1}/{max_retries + 1}")
set_stage(f"llm.gemini.{scope}.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await asyncio.wait_for(
self._client.aio.models.generate_content(
@@ -479,6 +573,17 @@ class GeminiLLM(LLMInterface):
logger.error(f"Gemini auth error (HTTP {e.code}), not retrying: {str(e)}")
raise
# Diagnostic dump (opt-in) of the exact request behind any 4xx, captured
# before the cache-drop retry below rebuilds the config so we see what failed.
dump_request_on_4xx(
scope=scope,
provider=self.provider,
model=self.model,
err=e,
request=generation_config,
messages=gemini_contents,
)
# 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
@@ -525,8 +630,9 @@ class GeminiLLM(LLMInterface):
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
cached_prefix: str | None = None,
cached_prefix_message_count: int = 0,
) -> LLMToolCallResult:
"""
Make a Gemini/VertexAI API call with tool/function calling support.
@@ -540,15 +646,22 @@ class GeminiLLM(LLMInterface):
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools (Gemini uses "auto" only).
tool_choice: Canonical tool-selection policy.
cached_prefix: Optional CachedContent resource name (from
``GeminiCacheManager.get_or_create`` with ``tools=...``). When
set, the system_instruction and tool definitions are assumed
``GeminiCacheManager.get_or_create`` or ``create_incremental``).
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.
cached_prefix_message_count: Number of leading ``messages`` already
baked into ``cached_prefix`` (the step-by-step reflect cache holds
a growing conversation prefix, not just system+tools). Only the
messages AFTER this index are sent as request contents the rest
come from the cache and bill at the cached rate. 0 means the cache
holds only the static prefix (system+tools), so the full
conversation is still sent (legacy behaviour).
Returns:
LLMToolCallResult with content and/or tool_calls.
@@ -556,86 +669,45 @@ class GeminiLLM(LLMInterface):
start_time = time.time()
using_cache = cached_prefix is not None
# 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``.
# Convert tools to Gemini format. While the cache is in use the tool
# definitions live in the CachedContent and the SDK rejects re-sending
# them alongside ``cached_content`` (see ``_build_tools_config``), but we
# still build them unconditionally so the cached-call-failed fallback —
# which drops the cache and re-sends prefix + tools inline — has real
# tools to send rather than an empty list.
gemini_tools = []
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
gemini_contents = []
msg_list = list(messages)
i = 0
while i < len(msg_list):
msg = msg_list[i]
role = msg.get("role", "user")
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":
# Gemini requires ALL tool responses for a given model turn to be grouped
# into a single Content with multiple FunctionResponse parts.
# Consecutive role="tool" messages correspond to one model turn's tool calls.
parts = []
while i < len(msg_list) and msg_list[i].get("role") == "tool":
tool_msg = msg_list[i]
tool_content = tool_msg.get("content", "")
parts.append(
genai_types.Part(
function_response=genai_types.FunctionResponse(
name=tool_msg.get("name", ""),
response={"result": tool_content},
)
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"),
)
)
i += 1
gemini_contents.append(genai_types.Content(role="user", parts=parts))
elif role == "assistant":
tool_calls_in_msg = msg.get("tool_calls", [])
if tool_calls_in_msg:
# Convert OpenAI-style tool_calls to Gemini function_call parts
# This is required for proper multi-turn conversation history
parts = []
if content:
parts.append(genai_types.Part(text=content))
for tc in tool_calls_in_msg:
fn = tc.get("function", {})
fn_name = fn.get("name", "")
fn_args_str = fn.get("arguments", "{}")
fn_args = parse_llm_json(fn_args_str)
thought_signature = tc.get("thought_signature")
fc_kwargs: dict[str, Any] = {"name": fn_name, "args": fn_args}
part_kwargs: dict[str, Any] = {"function_call": genai_types.FunctionCall(**fc_kwargs)}
if thought_signature:
part_kwargs["thought_signature"] = base64.b64decode(thought_signature)
parts.append(genai_types.Part(**part_kwargs))
gemini_contents.append(genai_types.Content(role="model", parts=parts))
else:
gemini_contents.append(genai_types.Content(role="model", parts=[genai_types.Part(text=content)]))
i += 1
else:
gemini_contents.append(genai_types.Content(role="user", parts=[genai_types.Part(text=content)]))
i += 1
]
)
)
# Convert messages. ``system_instruction`` and the FULL contents are always
# computed: _build_tools_config omits system/tools from the request while
# the cache carries the prefix, but the cached-call-failed safety net must
# be able to re-send the whole prefix + tools inline.
converted = _convert_messages_to_gemini(list(messages))
system_instruction = converted.system_instruction
full_contents = converted.contents
# Step-by-step reflect cache: when the cache already holds the first
# ``cached_prefix_message_count`` messages, send ONLY the newer turns as
# request contents — the cached prefix supplies the rest at the cached
# rate. The split is always at a whole-turn boundary (the reflect loop
# advances the cache one completed turn at a time), so slicing the raw
# messages before conversion never splits a grouped tool turn.
if using_cache and cached_prefix_message_count > 0:
delta_contents = _convert_messages_to_gemini(list(messages)[cached_prefix_message_count:]).contents
else:
delta_contents = full_contents
# Apply safety settings: context var (per-request bank override) takes precedence over instance default
effective_safety_settings = _safety_settings_ctx.get()
@@ -665,22 +737,20 @@ class GeminiLLM(LLMInterface):
config_kwargs["max_output_tokens"] = max_completion_tokens
# Map OpenAI-style tool_choice to Gemini FunctionCallingConfig
if tool_choice == "required":
if tool_choice.mode is LLMToolChoiceMode.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.mode is LLMToolChoiceMode.NAMED:
config_kwargs["tool_config"] = genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(
mode="ANY",
allowed_function_names=[tool_choice.selected_function_name],
)
elif tool_choice == "none":
)
elif tool_choice.mode is LLMToolChoiceMode.NONE:
config_kwargs["tool_config"] = genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(mode="NONE")
)
@@ -698,13 +768,16 @@ class GeminiLLM(LLMInterface):
last_exception = None
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.gemini.tools.attempt={attempt + 1}/{max_retries + 1}")
set_stage(f"llm.gemini.tools.attempt={attempt + 1}/{max_retries + 1}")
try:
# With the cache active, send only the un-cached tail (delta);
# on the uncached fallback path send the full conversation so the
# re-inlined system+tools prefix has its whole context.
active_contents = delta_contents if cache_active else full_contents
response = await asyncio.wait_for(
self._client.aio.models.generate_content(
model=self.model,
contents=gemini_contents,
contents=active_contents,
config=config,
),
timeout=90.0, # Safety net for network hangs; valid slow responses are <90s
@@ -807,6 +880,17 @@ class GeminiLLM(LLMInterface):
logger.error(f"Gemini auth error (HTTP {e.code}), not retrying: {str(e)}")
raise
# Diagnostic dump (opt-in) of the exact request behind any 4xx, captured
# before the cache-drop retry below rebuilds the config so we see what failed.
dump_request_on_4xx(
scope=scope,
provider=self.provider,
model=self.model,
err=e,
request=config,
messages=active_contents,
)
# 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
@@ -883,6 +967,56 @@ class GeminiLLM(LLMInterface):
tools=tools,
)
# ── Step-by-step incremental prompt caching (reflect tool loop) ──────────
def supports_incremental_prompt_cache(self) -> bool:
"""True when explicit caching is on — the reflect loop can then roll a
per-step CachedContent that grows with the conversation."""
return self._prompt_cache_enabled
def _ensure_cache_manager(self) -> Any:
if self._cache_manager is None:
from hindsight_api.engine.providers.gemini_cache import GeminiCacheManager
self._cache_manager = GeminiCacheManager(self._client)
return self._cache_manager
async def create_incremental_cache(
self,
*,
session_id: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
) -> str | None:
"""Cache ``system + tools + messages`` as a conversation prefix and return
its resource name (or ``None`` caller falls back to an uncached call).
The reflect loop calls this once per step with the growing message list so
each step's cache entirely contains the previous step's input; the next
model turn then references it and re-sends only its own delta. Caches are
tracked under ``session_id`` and torn down by ``delete_cache_session``.
"""
if not self._prompt_cache_enabled or self._client is None:
return None
converted = _convert_messages_to_gemini(list(messages))
return await self._ensure_cache_manager().create_incremental(
session_id=session_id,
model=self.model,
system_instruction=converted.system_instruction or "",
contents=converted.contents,
tools=tools,
)
async def delete_cached_prefix(self, name: str) -> None:
"""Best-effort delete of a single CachedContent (superseded reflect step)."""
if self._cache_manager is not None:
await self._cache_manager.delete(name)
async def delete_cache_session(self, session_id: str) -> None:
"""Tear down every CachedContent created for a reflect session."""
if self._cache_manager is not None:
await self._cache_manager.delete_session(session_id)
# ── Batch API (Gemini API only — not Vertex AI) ─────────────────────────
#
# Google's Gemini Batch API gives a flat 50% discount on input + output
@@ -1030,7 +1164,7 @@ class GeminiLLM(LLMInterface):
Mirrors the synchronous ``call`` path: system messages become
``systemInstruction``; a ``response_format`` json_schema forces JSON
output (``responseMimeType``), appends the schema as a textual hint, and
grammar-enforces via ``responseJsonSchema`` when ``strict`` is set.
grammar-enforces via ``responseJsonSchema`` whenever a schema is present.
"""
system_texts: list[str] = []
contents: list[dict[str, Any]] = []
@@ -1059,8 +1193,13 @@ class GeminiLLM(LLMInterface):
system_texts.append(
"You must respond with valid JSON matching this schema:\n" + json.dumps(schema, ensure_ascii=False)
)
if json_schema.get("strict"):
generation_config["responseJsonSchema"] = schema
# #2699: Gemini always grammar-enforces structured output via its native
# response_schema (``strict`` is an OpenAI concept, meaningless here). Set
# the native schema whenever one is present so the batch path mirrors the
# interactive path; otherwise batch requests at default config
# (HINDSIGHT_API_LLM_STRICT_SCHEMA=False) get only a textual hint and
# intermittently emit malformed JSON, losing every fact in the chunk.
generation_config["responseJsonSchema"] = schema
request: dict[str, Any] = {"contents": contents}
if system_texts:
@@ -22,9 +22,18 @@ from typing import Any
from litellm.exceptions import Timeout as LiteLLMTimeout
from hindsight_api.config import DEFAULT_LLM_TIMEOUT, ENV_LLM_TIMEOUT
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.llm_interface import (
LLM_TOOL_CHOICE_AUTO,
LLMInterface,
LLMToolChoice,
LLMToolChoiceMode,
OutputTooLongError,
)
from hindsight_api.engine.llm_trace import LLMResponseUsage, stash_response_usage
from hindsight_api.engine.llm_wrapper import parse_llm_json
from hindsight_api.engine.providers.llm_debug import dump_request_on_4xx
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.engine.structured_output import strict_json_schema
from hindsight_api.metrics import get_metrics_collector
from hindsight_api.worker.stage import set_stage
@@ -233,7 +242,7 @@ class LiteLLMLLM(LLMInterface):
# Add JSON schema response format if provided
if response_format is not None and hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
schema = strict_json_schema(response_format) if strict_schema else response_format.model_json_schema()
call_kwargs["response_format"] = {
"type": "json_schema",
"json_schema": {
@@ -246,8 +255,7 @@ class LiteLLMLLM(LLMInterface):
last_exception = None
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.{self._stage_label}.{scope}.attempt={attempt + 1}/{max_retries + 1}")
set_stage(f"llm.{self._stage_label}.{scope}.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await asyncio.wait_for(
self._acompletion(**call_kwargs),
@@ -277,7 +285,17 @@ class LiteLLMLLM(LLMInterface):
try:
json_data = json.loads(clean_content)
except json.JSONDecodeError:
json_data = json.loads(content)
try:
json_data = json.loads(content)
except json.JSONDecodeError:
if attempt < max_retries:
# Prefer a clean re-roll first — a fresh generation
# usually beats repairing a malformed one.
raise
# Retry budget spent: structural repair as a last
# resort (#2547/#2544). Raises again if unrecoverable,
# which the outer handler surfaces loudly.
json_data = parse_llm_json(content)
if skip_validation:
result = json_data
@@ -378,6 +396,9 @@ class LiteLLMLLM(LLMInterface):
logger.error(f"LiteLLM auth error, not retrying: {e}")
raise
# Diagnostic dump (opt-in) of the exact request behind any 4xx.
dump_request_on_4xx(scope=scope, provider=self.provider, model=self.model, err=e, request=call_kwargs)
last_exception = e
if attempt < max_retries:
# Retry on rate limits, connection errors, server errors
@@ -408,18 +429,24 @@ class LiteLLMLLM(LLMInterface):
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
) -> LLMToolCallResult:
start_time = time.time()
call_kwargs = self._build_common_kwargs(messages, max_completion_tokens, temperature)
call_kwargs["tools"] = tools
call_kwargs["tool_choice"] = tool_choice
call_kwargs["tool_choice"] = (
{
"type": "function",
"function": {"name": tool_choice.selected_function_name},
}
if tool_choice.mode is LLMToolChoiceMode.NAMED
else tool_choice.mode.value
)
last_exception = None
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.{self._stage_label}.tools.attempt={attempt + 1}/{max_retries + 1}")
set_stage(f"llm.{self._stage_label}.tools.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await asyncio.wait_for(
self._acompletion(**call_kwargs),
@@ -524,6 +551,9 @@ class LiteLLMLLM(LLMInterface):
if "401" in error_str or "403" in error_str or "unauthorized" in error_str:
raise
# Diagnostic dump (opt-in) of the exact request behind any 4xx.
dump_request_on_4xx(scope=scope, provider=self.provider, model=self.model, err=e, request=call_kwargs)
last_exception = e
if attempt < max_retries:
is_retryable = any(
@@ -22,7 +22,7 @@ import time
from pathlib import Path
from typing import Any
from hindsight_api.engine.llm_interface import LLMInterface
from hindsight_api.engine.llm_interface import LLM_TOOL_CHOICE_AUTO, LLMInterface, LLMToolChoice
from hindsight_api.engine.response_models import LLMToolCallResult
logger = logging.getLogger(__name__)
@@ -394,7 +394,7 @@ class LlamaCppLLM(LLMInterface):
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
) -> LLMToolCallResult:
"""Delegate tool calls to the OpenAI-compatible API."""
await self._ensure_initialized()
@@ -0,0 +1,168 @@
"""Opt-in diagnostic: dump the exact request behind an LLM 4xx rejection.
Some ``400 INVALID_ARGUMENT`` / ``400 Bad Request`` rejections of structured-output
calls are not reproducible by reconstructing the request after the fact the failing
factor lives in the request as it was actually assembled at runtime. Reconstructed
replays of the same inputs return ``200``, so the only reliable way to see what the
model rejected is to capture the real request at the moment it fails.
This helper is provider-agnostic. Every provider's error handler calls
``dump_request_on_4xx`` with whatever it assembled a Pydantic config
(google-genai ``GenerateContentConfig``), a kwargs dict (OpenAI / Anthropic /
LiteLLM ``**call_params``), etc. plus the raised error. The helper self-gates:
it is a no-op unless the ``llm_debug_dump_4xx`` config flag
(``HINDSIGHT_API_LLM_DEBUG_DUMP_4XX``) is enabled AND the error carries a 4xx
status, so callers can drop one unconditional call into each ``except`` block.
Safety / scope:
- Off by default the config flag is unset in normal operation.
- The serialized config omits message bodies (the ``messages``/``contents``/``input``
keys are stripped); message previews are length-capped, so an enabled dump can't
flood logs or spill large bodies.
- Never raises diagnostics must not break the request path (falls back to ``repr``).
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
# Top-level request keys whose values are message bodies. Stripped from the config
# view so the dump never spills large user content — previews are logged separately.
_CONTENT_KEYS = ("messages", "contents", "input")
_PREVIEW_CHARS = 1500
_CONFIG_REPR_CAP = 8000
_ERR_CAP = 200
def _enabled() -> bool:
from hindsight_api.config import get_config
return bool(get_config().llm_debug_dump_4xx)
def status_code_of(err: Any) -> int | None:
"""Best-effort HTTP status of a provider error, across SDK error shapes.
OpenAI/Anthropic expose ``status_code``; google-genai uses ``code``; some wrap the
status on a ``response``. Returns None when no integer status is discoverable.
"""
for attr in ("status_code", "code", "http_status"):
value = getattr(err, attr, None)
if isinstance(value, int):
return value
response = getattr(err, "response", None)
if response is not None:
value = getattr(response, "status_code", None)
if isinstance(value, int):
return value
return None
def _serialize_config(request: Any) -> str:
"""Render the request config to a string without message bodies, never raising."""
try:
if request is None:
return "null"
# Pydantic models (google-genai GenerateContentConfig, SDK params objects).
dump = getattr(request, "model_dump_json", None)
if callable(dump):
return dump(exclude_none=True)
if isinstance(request, dict):
view = {k: v for k, v in request.items() if k not in _CONTENT_KEYS}
return json.dumps(view, ensure_ascii=False, default=str)
return repr(request)[:_CONFIG_REPR_CAP]
except Exception:
return repr(request)[:_CONFIG_REPR_CAP]
@dataclass
class _MessagePreview:
"""A message rendered for the dump: role + extracted text (not yet length-capped)."""
role: str
text: str
def _message_preview(msg: Any) -> _MessagePreview:
"""Extract role + text from a message across dict and provider-object shapes."""
# OpenAI / Anthropic dict: {"role": ..., "content": str | list[block]}
if isinstance(msg, dict):
role = str(msg.get("role", "?"))
content = msg.get("content")
if isinstance(content, str):
return _MessagePreview(role, content)
if isinstance(content, list):
text = ""
for block in content:
if isinstance(block, dict):
text += block.get("text") or ""
else:
text += getattr(block, "text", "") or ""
return _MessagePreview(role, text)
return _MessagePreview(role, "" if content is None else str(content))
# google-genai Content: role + parts[].text
role = str(getattr(msg, "role", "?"))
text = ""
for part in getattr(msg, "parts", None) or []:
text += getattr(part, "text", None) or ""
if not text:
text = getattr(msg, "content", "") or ""
return _MessagePreview(role, text)
def _resolve_messages(request: Any, messages: Any) -> Any:
"""Where per-message previews come from: explicit ``messages``, else inside ``request``."""
if messages is not None:
return messages
if isinstance(request, dict):
for key in _CONTENT_KEYS:
if key in request:
return request[key]
return []
def dump_request_on_4xx(
*,
scope: str,
provider: str,
model: str,
err: Any,
request: Any = None,
messages: Any = None,
) -> None:
"""Log the exact request behind an LLM 4xx when the diagnostic is enabled.
No-op unless ``HINDSIGHT_API_LLM_DEBUG_DUMP_4XX`` is truthy and ``err`` carries a
4xx status. ``request`` is whatever the provider assembled (a Pydantic config, a
kwargs dict, ...); ``messages`` overrides where the per-message previews come from
(defaults to the message list found inside ``request``).
"""
if not _enabled():
return
code = status_code_of(err)
if code is None or not (400 <= code < 500):
return
try:
cfg_repr = _serialize_config(request)
summary = []
for msg in _resolve_messages(request, messages) or []:
m = _message_preview(msg)
summary.append({"role": m.role, "chars": len(m.text), "preview": m.text[:_PREVIEW_CHARS]})
logger.error(
"[LLM_4XX_DUMP] provider=%s model=%s scope=%s code=%s err=%s config=%s contents=%s",
provider,
model,
scope,
code,
str(err)[:_ERR_CAP],
cfg_repr,
json.dumps(summary, ensure_ascii=False),
)
except Exception as dump_exc: # never let diagnostics break the request path
logger.warning("[LLM_4XX_DUMP] failed to serialize rejected request: %s", dump_exc)
@@ -9,7 +9,7 @@ import logging
from collections.abc import Callable
from typing import Any
from ..llm_interface import LLMInterface
from ..llm_interface import LLM_TOOL_CHOICE_AUTO, LLMInterface, LLMToolChoice, LLMToolChoiceMode
from ..response_models import LLMToolCall, LLMToolCallResult, TokenUsage
logger = logging.getLogger(__name__)
@@ -200,7 +200,7 @@ class MockLLM(LLMInterface):
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
) -> LLMToolCallResult:
"""
Make a mock LLM API call with tool/function calling support.
@@ -266,7 +266,7 @@ class MockLLM(LLMInterface):
else:
result = LLMToolCallResult(content="mock response", finish_reason="stop")
else:
result = LLMToolCallResult(content="mock response", finish_reason="stop")
result = self._compliant_tool_call(tools, tool_choice, messages)
# Set mock token usage on result if not already set
if result.input_tokens == 0:
@@ -297,6 +297,61 @@ class MockLLM(LLMInterface):
return result
@staticmethod
def _compliant_tool_call(
tools: list[dict[str, Any]],
tool_choice: LLMToolChoice,
messages: list[dict[str, Any]],
) -> LLMToolCallResult:
"""Default tool response: simulate a compliant tool-calling model.
Real providers drive the reflect loop entirely through tool calls -- they
honor a forced tool choice, then finish via ``done`` -- and the reflect
agent now rejects a turn that yields no tool call at all (a transport that
can't tool-call raises ReflectToolCallError). So the mock must behave like a
working provider here rather than returning bare "mock response" prose,
which used to be salvaged as the answer. Only this default path is affected;
tests that script turns via ``_response_callback`` / ``_mock_response`` are not.
"""
tool_names = {t.get("function", {}).get("name") for t in tools}
def _mock_query() -> str:
for message in reversed(messages):
content = message.get("content")
if message.get("role") == "user" and isinstance(content, str) and content.strip():
return content[:200]
return "mock query"
# Honor a forced retrieval tool so the loop actually runs recall/search and
# gathers evidence (populates based_on for tests that assert on it).
if tool_choice.mode is LLMToolChoiceMode.NAMED and tool_choice.function_name in {
"search_mental_models",
"search_observations",
"recall",
}:
return LLMToolCallResult(
tool_calls=[
LLMToolCall(
id="mock_forced",
name=tool_choice.function_name,
arguments={"reason": "mock", "query": _mock_query()},
)
],
finish_reason="tool_calls",
)
# Auto turn: finish via the done tool, mirroring a model that has gathered
# enough. The reflect evidence guardrail handles the empty-bank case (no
# evidence -> forced text synthesis on the final iteration).
if "done" in tool_names:
return LLMToolCallResult(
tool_calls=[LLMToolCall(id="mock_done", name="done", arguments={"answer": "mock response"})],
finish_reason="tool_calls",
)
# No done tool offered (non-reflect tool call): fall back to plain text.
return LLMToolCallResult(content="mock response", finish_reason="stop")
@staticmethod
def _build_mock_facts(messages: list[dict]) -> dict:
"""Build a canned fact extraction response from the user message text.
@@ -10,7 +10,7 @@ it raises a clear error instead of a confusing connection failure.
import logging
from typing import Any
from ..llm_interface import LLMInterface
from ..llm_interface import LLM_TOOL_CHOICE_AUTO, LLMInterface, LLMToolChoice
from ..response_models import LLMToolCallResult
logger = logging.getLogger(__name__)
@@ -65,7 +65,7 @@ class NoneLLM(LLMInterface):
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
) -> LLMToolCallResult:
"""Raise LLMNotAvailableError — no LLM is configured."""
raise LLMNotAvailableError(
@@ -36,9 +36,18 @@ from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinish
from hindsight_api.config import DEFAULT_LLM_TIMEOUT, ENV_LLM_TIMEOUT
from hindsight_api.engine.bank_attribution import apply_bank_attribution
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError, ProviderRateLimitResetError
from hindsight_api.engine.llm_interface import (
LLM_TOOL_CHOICE_AUTO,
LLMInterface,
LLMToolChoice,
LLMToolChoiceMode,
OutputTooLongError,
ProviderRateLimitResetError,
)
from hindsight_api.engine.llm_trace import LLMResponseUsage, stash_response_usage
from hindsight_api.engine.providers.llm_debug import dump_request_on_4xx
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.engine.structured_output import strict_json_schema
from hindsight_api.metrics import get_metrics_collector
from hindsight_api.worker.stage import set_stage
@@ -47,17 +56,57 @@ logger = logging.getLogger(__name__)
# Seed applied to every Groq request for deterministic behavior
DEFAULT_LLM_SEED = 4242
JSON_MODE_USER_HINT = "Return valid json only."
DEFAULT_VERIFICATION_MAX_COMPLETION_TOKENS = 512
# Self-hosted OpenAI-compatible servers that advertise tool_choice="required"
def _validate_ollama_num_ctx(value: Any) -> int | None:
"""Validate a native Ollama context-window override."""
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"ollama_num_ctx must be a positive integer, got {value!r}")
if value < 1:
raise ValueError(f"ollama_num_ctx must be >= 1, got {value}")
return value
# Provider implementations 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).
# --enable-auto-tool-choice). The generic OpenAI provider is intentionally not
# inferred from its URL: custom OpenAI-compatible endpoints can implement the
# required-tool contract, and silently downgrading them changes request semantics.
# llama-server (the "llamacpp" provider) honors "required" correctly and is
# intentionally excluded (#1179).
_TOOL_CHOICE_REQUIRED_UNSUPPORTED_PROVIDERS = frozenset({"lmstudio", "ollama"})
# Local providers whose OpenAI-compatible surface always lives under a `/v1`
# path (LM Studio: http://localhost:1234/v1, Ollama: http://localhost:11434/v1).
# For these we know the exact endpoint shape, so a bare host base URL can be
# normalized safely. Cloud/proxy endpoints are left untouched — their path is
# provider-specific and must be supplied verbatim.
_V1_PATH_LOCAL_PROVIDERS = frozenset({"lmstudio", "ollama"})
def _ensure_v1_base_url(base_url: str) -> str:
"""Append the OpenAI-compatible ``/v1`` prefix to a bare local base URL.
LM Studio's server UI advertises its address as ``http://localhost:1234``,
so users commonly set ``HINDSIGHT_API_LLM_BASE_URL`` to that bare host. The
OpenAI SDK then POSTs to ``<host>/chat/completions`` and LM Studio rejects it
with ``Unexpected endpoint or method`` its OpenAI-compatible routes live
under ``/v1``. Only a base URL with no meaningful path (bare host or a lone
trailing slash) is rewritten; anything with an explicit path (e.g. a reverse
proxy mount or an already-correct ``/v1``) is returned unchanged. See #2922.
"""
parsed = urlparse(base_url)
if parsed.path.strip("/"):
return base_url
return urlunparse(parsed._replace(path="/v1"))
class ProviderResponseError(RuntimeError):
"""Raised when a provider returns a success response without usable content."""
@@ -67,23 +116,68 @@ class ProviderResponseError(RuntimeError):
self.retryable = retryable
def _is_json(text: str) -> bool:
"""True if ``text`` parses as a JSON value."""
try:
json.loads(text)
except (json.JSONDecodeError, ValueError):
return False
return True
def _outer_json_span(content: str) -> str | None:
"""Return the outermost ``{...}`` / ``[...]`` span if it parses as JSON, else None.
Fallback for responses where fences are partial/absent or the model wrapped
the JSON in surrounding prose. Only returned when it is valid JSON so callers
never receive a worse candidate than the raw content.
"""
starts = [i for i in (content.find("{"), content.find("[")) if i >= 0]
ends = [i for i in (content.rfind("}"), content.rfind("]")) if i >= 0]
if not starts or not ends:
return None
start, end = min(starts), max(ends)
if end <= start:
return None
candidate = content[start : end + 1].strip()
return candidate if _is_json(candidate) else None
def _strip_code_fences(content: str) -> str:
"""Strip markdown code fences from LLM response if present.
Many LLM providers (MiniMax, some Ollama models, Claude via proxies)
wrap JSON responses in ```json ... ``` fences even when json_object
response format is requested. This strips the fences while preserving
the JSON content inside. Returns the original content unchanged if
no fences are detected.
response format is requested. Fences are detected by line (a closing
``` must sit alone on its line) so triple-backticks *inside* JSON string
values do not truncate the payload. When the stripped candidate is not
valid JSON (partial fence, prose-wrapped output, truncated response), fall
back to the outermost parseable JSON span. Returns the original content
unchanged if no better candidate is found.
"""
if "```" not in content:
return content
try:
if "```json" in content:
return content.split("```json")[1].split("```")[0].strip()
return content.split("```")[1].split("```")[0].strip()
except (IndexError, ValueError):
return content
candidate = content
if "```" in content:
lines = content.split("\n")
# Find first line that starts a code fence (``` optionally followed by language)
fence_start = next((i for i, line in enumerate(lines) if line.startswith("```")), None)
if fence_start is not None:
# Find matching closing fence (``` alone or with trailing whitespace)
fence_end = next(
(j for j in range(fence_start + 1, len(lines)) if lines[j].strip() == "```"),
None,
)
if fence_end is not None:
candidate = "\n".join(lines[fence_start + 1 : fence_end]).strip()
if _is_json(candidate):
return candidate
# Fence stripping did not yield valid JSON — try to recover the outer JSON span.
span = _outer_json_span(content)
if span is not None:
return span
return candidate
# Reasoning/thinking tags emitted by extended-thinking models. Some providers
@@ -435,6 +529,8 @@ class OpenAICompatibleLLM(LLMInterface):
timeout: float | None = None,
groq_service_tier: str | None = None,
extra_body: dict[str, Any] | None = None,
*,
ollama_num_ctx: int | None = None,
**kwargs: Any,
):
"""
@@ -445,10 +541,15 @@ class OpenAICompatibleLLM(LLMInterface):
api_key: API key (optional for ollama/lmstudio).
base_url: Base URL for the API (uses defaults for groq/ollama/lmstudio if empty).
model: Model name.
reasoning_effort: Reasoning effort level for supported models ("low", "medium", "high").
reasoning_effort: Reasoning effort level for supported models
("none", "low", "medium", "high"). "none" is required when calling
function tools on some reasoning models, which reject every other
value including omitting the parameter entirely.
timeout: Request timeout in seconds (uses env var or 120s default).
groq_service_tier: Groq service tier ("on_demand", "flex", "auto").
extra_body: Extra body params merged into every API call.
ollama_num_ctx: Native Ollama context window override. None lets Ollama use
the model/server default.
**kwargs: Additional provider-specific parameters.
"""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
@@ -503,6 +604,11 @@ class OpenAICompatibleLLM(LLMInterface):
# lives on a separate control-plane host — see FireworksLLM.
self.base_url = "https://api.fireworks.ai/inference/v1"
# Normalize bare local base URLs (e.g. a user pasting the address shown
# in the LM Studio UI) so the OpenAI SDK targets the `/v1` routes. See #2922.
if self.provider in _V1_PATH_LOCAL_PROVIDERS and self.base_url:
self.base_url = _ensure_v1_base_url(self.base_url)
# For ollama/lmstudio, use dummy key if not provided
if self.provider in ("ollama", "lmstudio") and not self.api_key:
self.api_key = "local"
@@ -529,6 +635,7 @@ class OpenAICompatibleLLM(LLMInterface):
# Service tier configuration (from config, not env vars)
self.groq_service_tier = groq_service_tier
self.openai_service_tier = kwargs.get("openai_service_tier")
self.ollama_num_ctx = _validate_ollama_num_ctx(ollama_num_ctx)
# User-configured extra body params (merged into every API call)
self._config_extra_body = extra_body or {}
@@ -559,17 +666,17 @@ class OpenAICompatibleLLM(LLMInterface):
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.
Only explicitly identified provider implementations are classified as
unsupported. A custom base URL does not identify endpoint capabilities:
an OpenAI-compatible endpoint may correctly enforce required tool calls,
and replacing ``required`` with ``auto`` would violate the caller's named
tool choice after the tools list has been narrowed.
"""
if self.provider in _TOOL_CHOICE_REQUIRED_UNSUPPORTED_PROVIDERS:
return True
return self.provider == "openai" and bool(self.base_url)
return self.provider in _TOOL_CHOICE_REQUIRED_UNSUPPORTED_PROVIDERS
def _verification_max_completion_tokens(self) -> int:
"""Return the startup verification budget for OpenAI-compatible gateways."""
return DEFAULT_VERIFICATION_MAX_COMPLETION_TOKENS
async def verify_connection(self) -> None:
"""
@@ -582,7 +689,7 @@ class OpenAICompatibleLLM(LLMInterface):
logger.info(f"Verifying connection: {self.provider}/{self.model}")
await self.call(
messages=[{"role": "user", "content": "Say 'ok'"}],
max_completion_tokens=100,
max_completion_tokens=self._verification_max_completion_tokens(),
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
@@ -644,6 +751,11 @@ class OpenAICompatibleLLM(LLMInterface):
# use the widely-supported max_tokens
return "max_tokens"
def _apply_provider_extra_body_defaults(self, extra_body: dict[str, Any]) -> None:
"""Apply provider-specific extra_body defaults while preserving user overrides."""
if self.provider == "minimax":
extra_body.setdefault("thinking", {"type": "disabled"})
async def call(
self,
messages: list[dict[str, str]],
@@ -731,6 +843,7 @@ class OpenAICompatibleLLM(LLMInterface):
# Provider-specific parameters
extra_body: dict[str, Any] = {**self._config_extra_body}
self._apply_provider_extra_body_defaults(extra_body)
if self.provider == "groq":
call_params["seed"] = DEFAULT_LLM_SEED
# Add service_tier if configured
@@ -746,7 +859,7 @@ class OpenAICompatibleLLM(LLMInterface):
if response_format is not None:
schema = None
if hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
schema = strict_json_schema(response_format) if strict_schema else response_format.model_json_schema()
if strict_schema and schema is not None:
# Use OpenAI's strict JSON schema enforcement
@@ -789,8 +902,7 @@ class OpenAICompatibleLLM(LLMInterface):
# Surface attempt count in worker stage so JSON-schema retry loops
# are visible from logs (small models on strict structured output
# often loop here). Cheap no-op outside worker context.
if attempt > 0:
set_stage(f"llm.{self.provider}.{scope}.attempt={attempt + 1}/{max_retries + 1}")
set_stage(f"llm.{self.provider}.{scope}.attempt={attempt + 1}/{max_retries + 1}")
try:
if response_format is not None:
response = await self._client.chat.completions.create(**call_params)
@@ -886,7 +998,9 @@ class OpenAICompatibleLLM(LLMInterface):
output_tokens = max(0, output_tokens - thoughts_tokens)
total_tokens = max(0, total_tokens - thoughts_tokens)
# Record LLM metrics
# Record LLM metrics. ``output_tokens`` is visible-only by now, so
# ``thoughts_tokens`` has to be recorded alongside it or the reasoning
# half of the billed output reaches no counter at all.
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
@@ -896,6 +1010,8 @@ class OpenAICompatibleLLM(LLMInterface):
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
cached_input_tokens=cached_tokens,
thoughts_tokens=thoughts_tokens,
)
# Record trace span
@@ -964,6 +1080,9 @@ class OpenAICompatibleLLM(LLMInterface):
logger.error(f"Auth error (HTTP {e.status_code}), not retrying: {str(e)}")
raise
# Diagnostic dump (opt-in) of the exact request behind any 4xx.
dump_request_on_4xx(scope=scope, provider=self.provider, model=self.model, err=e, request=call_params)
_raise_provider_quota_defer(
e, provider=self.provider, model=self.model, scope=scope, max_backoff=max_backoff
)
@@ -1054,7 +1173,7 @@ class OpenAICompatibleLLM(LLMInterface):
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
tool_choice: LLMToolChoice = LLM_TOOL_CHOICE_AUTO,
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support.
@@ -1068,51 +1187,43 @@ class OpenAICompatibleLLM(LLMInterface):
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or specific function.
tool_choice: Canonical tool-selection policy.
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
start_time = time.time()
request_tool_choice: str | dict[str, Any] | None = tool_choice
# Normalize named tool_choice dicts to "required" + filter tools.
# Some providers (e.g. LM Studio, Ollama) reject the OpenAI named format
# {"type": "function", "function": {"name": "..."}}. The semantics are
# identical to tool_choice="required" with the tools list restricted to
# just the requested tool, so we apply that transformation where supported.
if isinstance(request_tool_choice, dict) and request_tool_choice.get("type") == "function":
forced_name = request_tool_choice.get("function", {}).get("name")
if forced_name:
filtered = [t for t in tools if t.get("function", {}).get("name") == forced_name]
if filtered:
tools = filtered
request_tool_choice = "required"
request_tool_choice: str | None
if tool_choice.mode is LLMToolChoiceMode.NAMED:
forced_name = tool_choice.selected_function_name
filtered = [tool for tool in tools if tool.get("function", {}).get("name") == forced_name]
if len(filtered) != 1:
raise ValueError(
f"Named tool_choice must reference exactly one declared tool; "
f"found {len(filtered)} definitions for {forced_name!r}"
)
tools = filtered
request_tool_choice = LLMToolChoiceMode.REQUIRED.value
elif tool_choice.mode is LLMToolChoiceMode.AUTO:
request_tool_choice = None
else:
request_tool_choice = tool_choice.mode.value
# DeepSeek accepts tool calls but rejects explicit required/named
# tool_choice values. The tools list has already been narrowed for
# forced calls, so omitting tool_choice preserves the practical behavior.
if "deepseek" in self.model.lower() and request_tool_choice != "auto":
if "deepseek" in self.model.lower() and tool_choice.mode is not LLMToolChoiceMode.AUTO:
request_tool_choice = None
# "auto" is the OpenAI API default — omitting tool_choice is semantically
# identical. Some providers (e.g. DeepSeek's reasoner pathway, which
# deepseek-v4-flash falls into when thinking mode is enabled) reject the
# parameter outright, returning HTTP 400 even for value "auto". Sending it
# only when the caller asks for a non-default behaviour avoids those 400s
# without changing semantics for compliant providers.
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).
# LM Studio and Ollama silently drop tool_choice="required", returning an
# empty tool_calls array instead of forcing a call (#1563/#1179).
# 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():
# auto. Generic OpenAI-compatible endpoints retain the canonical
# ``required`` contract regardless of whether they use a custom base URL.
if request_tool_choice == LLMToolChoiceMode.REQUIRED.value and self._drops_tool_choice_required():
request_tool_choice = None
# DeepSeek tool-call replies can carry provider-specific reasoning_content.
@@ -1147,8 +1258,16 @@ class OpenAICompatibleLLM(LLMInterface):
temperature = max(0.01, min(temperature, 1.0))
call_params["temperature"] = temperature
# Set reasoning_effort for reasoning models, matching call(). Omitting it
# here is not a neutral default: OpenAI rejects function tools on a
# reasoning model unless reasoning_effort is present and set to "none",
# so leaving it out fails exactly like sending an unsupported value.
if self._supports_reasoning_model():
call_params["reasoning_effort"] = self.reasoning_effort
# Provider-specific parameters
extra_body: dict[str, Any] = {**self._config_extra_body}
self._apply_provider_extra_body_defaults(extra_body)
if self.provider == "groq":
call_params["seed"] = DEFAULT_LLM_SEED
if extra_body:
@@ -1159,8 +1278,7 @@ class OpenAICompatibleLLM(LLMInterface):
last_exception = None
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.{self.provider}.tools.attempt={attempt + 1}/{max_retries + 1}")
set_stage(f"llm.{self.provider}.tools.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await self._client.chat.completions.create(**call_params)
@@ -1196,6 +1314,8 @@ class OpenAICompatibleLLM(LLMInterface):
if thoughts_tokens:
output_tokens = max(0, output_tokens - thoughts_tokens)
# See ``call()``: record the reasoning and cached counts too, so no
# billed token is dropped from the metrics counters.
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
@@ -1205,6 +1325,8 @@ class OpenAICompatibleLLM(LLMInterface):
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
cached_input_tokens=cached_tokens,
thoughts_tokens=thoughts_tokens,
)
# Record OpenTelemetry span
@@ -1266,6 +1388,10 @@ class OpenAICompatibleLLM(LLMInterface):
f"not retrying: {_summarize_status_error(e)}"
)
raise
# Diagnostic dump (opt-in) of the exact request behind any 4xx.
dump_request_on_4xx(scope=scope, provider=self.provider, model=self.model, err=e, request=call_params)
_raise_provider_quota_defer(
e, provider=self.provider, model=self.model, scope=scope, max_backoff=max_backoff
)
@@ -1337,9 +1463,10 @@ class OpenAICompatibleLLM(LLMInterface):
# Add optional parameters with optimized defaults for Ollama
options: dict[str, Any] = {
"num_ctx": 16384, # 16k context window for larger prompts
"num_batch": 512, # Optimal batch size for prompt processing
}
if self.ollama_num_ctx is not None:
options["num_ctx"] = self.ollama_num_ctx
if max_completion_tokens:
options["num_predict"] = max_completion_tokens
if temperature is not None:
@@ -1355,8 +1482,7 @@ class OpenAICompatibleLLM(LLMInterface):
async with httpx.AsyncClient(timeout=300.0) as client:
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.ollama_native.{scope}.attempt={attempt + 1}/{max_retries + 1}")
set_stage(f"llm.ollama_native.{scope}.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await client.post(native_url, json=payload, headers=headers)
response.raise_for_status()
@@ -6,6 +6,7 @@ structured information like temporal constraints.
"""
import logging
import re
from abc import ABC, abstractmethod
from datetime import datetime, timedelta
@@ -19,6 +20,103 @@ from hindsight_api.engine.temporal_periods import (
logger = logging.getLogger(__name__)
# dateparser.search_dates over-matches: short common words that happen to be
# weekday/month abbreviations in *some* language ("we"/"me"/"did" -> a weekday,
# "do" -> Sunday) come back as bogus dates. When such a false positive appears
# *before* the real date in the query, taking the first match (or a hard-coded
# blacklist of such words) silently produces a wrong temporal window — worse
# than none, because the constraint is non-null so nothing downstream can tell
# extraction failed. See issue #2768.
#
# Instead of blacklisting words one at a time (a moving target — every short
# word dateparser resolves is a new instance of the same bug), we score each
# match by the date signal it actually carries and keep only matches with a
# real signal, preferring the strongest. A bare weekday abbreviation carries no
# day/month/year and scores zero, so it is rejected regardless of language or
# dateparser version.
_TOKEN_RE = re.compile(r"[a-z0-9]+")
_MONTH_WORDS = {
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december",
}
_RELATIVE_WORDS = {"today", "yesterday", "tomorrow", "tonight", "now"}
_WEEKDAY_WORDS = {
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
}
_PERIOD_WORDS = {
"last",
"next",
"this",
"past",
"coming",
"ago",
"week",
"weeks",
"month",
"months",
"year",
"years",
"day",
"days",
"hour",
"hours",
"minute",
"minutes",
"quarter",
"decade",
"century",
"weekend",
"morning",
"afternoon",
"evening",
"night",
"noon",
"midnight",
}
def _date_match_score(text: str) -> int:
"""Score how strong a temporal signal a matched span carries.
A score of 0 means the span is a bare token with no explicit date content
(the false-positive class from issue #2768) and should be rejected. Higher
scores mean a stronger, less ambiguous date reference. A digit is the
strongest signal (day/year/ISO date); an explicit English month/relative
word next; weekday names and period words weakest but still explicit.
"""
tokens = _TOKEN_RE.findall(text.lower())
if not tokens:
return 0
score = 0
if any(any(ch.isdigit() for ch in tok) for tok in tokens):
score += 100
token_set = set(tokens)
if token_set & _MONTH_WORDS:
score += 50
if token_set & _RELATIVE_WORDS:
score += 50
if token_set & _WEEKDAY_WORDS:
score += 30
if token_set & _PERIOD_WORDS:
score += 20
return score
class TemporalConstraint(BaseModel):
"""
@@ -164,20 +262,23 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
if not results:
return QueryAnalysis(temporal_constraint=None)
# Filter out false positives (common words parsed as dates)
false_positives = {"do", "may", "march", "will", "can", "sat", "sun", "mon", "tue", "wed", "thu", "fri"}
valid_results = [
(text, date)
# Score each match by the date signal it carries and keep only those
# with a real signal, rejecting bare weekday/month-abbreviation false
# positives ("we"/"me"/"did"). Prefer the strongest match, breaking ties
# by longest span, so an explicit date ("in May", "2026-06-10") always
# beats an earlier weak word regardless of position. See issue #2768.
scored_results = [
(_date_match_score(text), len(text), date)
for text, date in results
if (text.lower() not in false_positives or len(text) > 3)
and not is_embedded_cjk_dateparser_match(query, text)
if not is_embedded_cjk_dateparser_match(query, text)
]
scored_results = [entry for entry in scored_results if entry[0] > 0]
if not valid_results:
if not scored_results:
return QueryAnalysis(temporal_constraint=None)
# Use the first valid date found
_, parsed_date = valid_results[0]
# Highest signal score wins; ties broken by the longest matched span.
_, _, parsed_date = max(scored_results, key=lambda entry: (entry[0], entry[1]))
# Create constraint for single day
start_date = parsed_date.replace(hour=0, minute=0, second=0, microsecond=0)
@@ -7,12 +7,13 @@ The reflect agent uses an iterative loop with tools to:
3. Expand memories (get chunk/document context)
"""
from .agent import ReflectAgentResult, run_reflect_agent
from .agent import ReflectAgentResult, ReflectToolCallError, run_reflect_agent
from .models import ReflectAction, ReflectActionBatch
__all__ = [
"run_reflect_agent",
"ReflectAgentResult",
"ReflectToolCallError",
"ReflectAction",
"ReflectActionBatch",
]
@@ -10,11 +10,11 @@ Uses hierarchical retrieval:
import asyncio
import json
import logging
import re
import time
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from ...config import get_config
from ..llm_interface import LLM_TOOL_CHOICE_AUTO, LLMToolChoice
from .models import DirectiveInfo, LLMCall, ReflectAgentResult, StructuredOutputResult, TokenUsageSummary, ToolCall
from .prompts import (
_extract_directive_rules,
@@ -49,6 +49,25 @@ logger = logging.getLogger(__name__)
DEFAULT_MAX_ITERATIONS = 10
# Fallback answer when the LLM returns nothing usable. Consumers that need to
# tell a real answer from this placeholder (e.g. refresh outcome metadata's
# populated_content) compare against this constant rather than the literal.
NO_ANSWER_TEXT = "No answer provided."
class ReflectToolCallError(RuntimeError):
"""The model never produced a tool call reflect could understand.
Reflect is driven entirely by structured tool calls (``recall``, ``expand``,
``done`` ...). Some provider transports do not actually support function
calling and silently drop the tool definitions from the request (e.g. litellm's
Vertex AI gpt-oss MaaS path strips ``tools``/``tool_choice`` when the model is
flagged as not supporting them). The model then answers in free text that may
mimic a ``done`` payload. Rather than salvage that untooled text -- and risk
surfacing raw tool-call JSON as the answer -- we fail loudly so the caller can
switch to a tool-calling-capable model/transport.
"""
def _normalize_tool_name(name: str) -> str:
"""Normalize tool name from various LLM output formats.
@@ -82,148 +101,12 @@ def _is_done_tool(name: str) -> bool:
return _normalize_tool_name(name) == "done"
# Pattern to match done() call as text - handles done({...}) with nested JSON
_DONE_CALL_PATTERN = re.compile(r"done\s*\(\s*\{.*$", re.DOTALL)
# Patterns for leaked structured output in the answer field
_LEAKED_JSON_SUFFIX = re.compile(
r'\s*```(?:json)?\s*\{[^}]*(?:"(?:observation_ids|memory_ids|mental_model_ids)"|\})\s*```\s*$',
re.DOTALL | re.IGNORECASE,
)
_TRAILING_IDS_PATTERN = re.compile(
r"\s*(?:observation_ids|memory_ids|mental_model_ids)\s*[=:]\s*\[.*?\]\s*$", re.DOTALL | re.IGNORECASE
)
_JSON_CODE_FENCE_PATTERN = re.compile(r"^\s*```(?:json)?\s*(\{.*\})\s*```\s*$", re.DOTALL | re.IGNORECASE)
_DONE_ARGUMENT_KEYS = frozenset(
{
"answer",
"directive_compliance",
"memory_ids",
"mental_model_ids",
"observation_ids",
"model_ids",
}
)
_DONE_ARGUMENT_MARKER_KEYS = _DONE_ARGUMENT_KEYS - {"answer"}
_LEAKED_JSON_ID_KEYS = frozenset({"memory_ids", "mental_model_ids", "observation_ids", "model_ids"})
def _unwrap_leaked_done_arguments(text: str) -> str | None:
"""Return the answer when a done tool call was rendered as JSON text.
Some providers leak the done tool's argument object instead of surfacing it
as a native tool call, e.g. {"answer": "...", "memory_ids": [...]}. Only
unwrap objects that match the done argument shape so normal JSON answers
stay intact.
"""
candidate = text.strip()
if not candidate:
return None
fenced = _JSON_CODE_FENCE_PATTERN.match(candidate)
if fenced:
candidate = fenced.group(1).strip()
try:
payload = json.loads(candidate)
except json.JSONDecodeError:
return None
if not isinstance(payload, dict):
return None
answer = payload.get("answer")
if not isinstance(answer, str) or not answer.strip():
return None
keys = set(payload)
if not keys.intersection(_DONE_ARGUMENT_MARKER_KEYS):
return None
if not keys.issubset(_DONE_ARGUMENT_KEYS):
return None
for key in ("memory_ids", "mental_model_ids", "observation_ids", "model_ids"):
value = payload.get(key)
if value is not None and not isinstance(value, list):
return None
return answer.strip()
def _strip_trailing_id_json_object(text: str) -> str:
stripped = text.rstrip()
if not stripped.endswith("}"):
return text.strip()
start = stripped.rfind("{")
if start < 0:
return text.strip()
try:
payload = json.loads(stripped[start:])
except json.JSONDecodeError:
return text.strip()
if not isinstance(payload, dict) or not payload:
return text.strip()
keys = set(payload)
if not keys.issubset(_LEAKED_JSON_ID_KEYS):
return text.strip()
return stripped[:start].strip()
def _clean_answer_text(text: str) -> str:
"""Clean up answer text by removing any done() tool call syntax.
Some LLMs output the done() call as text instead of a proper tool call.
This strips out patterns like: done({"answer": "...", ...})
"""
unwrapped = _unwrap_leaked_done_arguments(text)
if unwrapped is not None:
return unwrapped
# Remove done() call pattern from the end of the text
cleaned = _DONE_CALL_PATTERN.sub("", text).strip()
return cleaned if cleaned else text
def _clean_done_answer(text: str) -> str:
"""Clean up the answer field from a done() tool call.
Some LLMs leak structured output patterns into the answer text, such as:
- JSON code blocks with observation_ids/memory_ids at the end
- Raw JSON objects with these fields
- Plain text like "observation_ids: [...]"
This cleans those patterns while preserving the actual answer content.
"""
if not text:
return text
unwrapped = _unwrap_leaked_done_arguments(text)
if unwrapped is not None:
return unwrapped
cleaned = text
# Remove leaked JSON in code blocks at the end
cleaned = _LEAKED_JSON_SUFFIX.sub("", cleaned).strip()
# Remove leaked raw JSON objects at the end
cleaned = _strip_trailing_id_json_object(cleaned)
# Remove trailing ID patterns
cleaned = _TRAILING_IDS_PATTERN.sub("", cleaned).strip()
return cleaned if cleaned else text
async def _generate_structured_output(
answer: str,
response_schema: dict,
llm_config: "LLMProvider",
reflect_id: str,
max_tokens: int | None = None,
) -> StructuredOutputResult:
"""Generate structured output from an answer using the provided JSON schema.
@@ -232,6 +115,10 @@ async def _generate_structured_output(
response_schema: JSON Schema for the expected output structure
llm_config: LLM provider for making the extraction call
reflect_id: Reflect ID for logging
max_tokens: Output-token budget for the extraction call, mirroring the
plain reflect calls (omitted when None); without it, reasoning /
preamble models can exhaust the provider default before emitting any
JSON (finish_reason=length, empty content -> issue #2431)
Returns:
A StructuredOutputResult carrying the structured output (None if
@@ -322,6 +209,8 @@ OUTPUT:"""
],
response_format=DynamicModel,
scope="reflect_structured",
strict_schema=get_config().llm_strict_schema_reflect,
max_completion_tokens=max_tokens,
max_retries=1,
initial_backoff=0.25,
max_backoff=1.0,
@@ -413,7 +302,104 @@ def _all_mental_models_are_usable_and_fresh(tool_output: dict[str, Any]) -> bool
return True
# Detached cache-teardown tasks. asyncio holds only weak references to tasks, so
# a fire-and-forget task can be garbage-collected mid-flight — keep a strong
# reference here until it finishes.
_cache_cleanup_tasks: set[asyncio.Task] = set()
def _spawn_cache_cleanup(
provider_impl: Any,
session_id: str,
cache_tasks: list[asyncio.Task],
reflect_id: str,
) -> None:
"""Delete a reflect's ephemeral context caches in the background.
The per-reflect caches are dead the moment the reflect returns nothing ever
reuses them so the caller must not wait on teardown: draining the in-flight
create plus the delete round-trips would add latency to every single answer.
Detach it instead. The short cache TTL is the backstop if the process dies
before the task runs.
"""
async def _cleanup() -> None:
try:
# Let any overlapped create land first, so its cache is registered in
# the session and actually gets deleted rather than lingering to TTL.
if cache_tasks:
await asyncio.gather(*cache_tasks, return_exceptions=True)
await provider_impl.delete_cache_session(session_id)
except Exception:
logger.debug("[REFLECT %s] cache session teardown failed (will age out on TTL)", reflect_id)
try:
task = asyncio.create_task(_cleanup())
except RuntimeError:
# No running loop to detach onto (not expected in the server); TTL cleans up.
return
_cache_cleanup_tasks.add(task)
task.add_done_callback(_cache_cleanup_tasks.discard)
async def run_reflect_agent(
llm_config: "LLMProvider",
bank_id: str,
query: str,
bank_profile: dict[str, Any],
search_mental_models_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
recall_fn: Callable[[str, int, int], Awaitable[dict[str, Any]]],
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
**kwargs: Any,
) -> ReflectAgentResult:
"""Public entrypoint: runs the agent loop and tears down any per-step context
caches it created.
The step-by-step caches (Gemini ``CachedContent``) are ephemeral scoped to
exactly one reflect and never reused after it so teardown is scheduled on
every exit path (answer, error, cancellation) but runs **detached**: the
caller gets its answer without waiting on the delete round-trips. The short
cache TTL is the backstop if the teardown never runs; the delete is
best-effort and never allowed to fail a reflect.
"""
reflect_id = f"{bank_id[:8]}-{int(time.time() * 1000) % 100000}"
provider_impl = getattr(llm_config, "_provider_impl", None)
# Reflect step-by-step caching needs the provider to support it AND the
# dedicated reflect flag (on by default; distinct from the global prompt-cache
# switch so it can be turned off for reflect alone).
incremental_caching = (
provider_impl is not None
and provider_impl.supports_incremental_prompt_cache()
and get_config().reflect_prompt_cache_enabled
)
cache_session_id = f"reflect:{reflect_id}"
# In-flight cache-create tasks (scheduled to overlap tool execution). Awaited
# before teardown so every created cache is tracked and deleted — no orphans.
cache_tasks: list[asyncio.Task] = []
try:
return await _run_reflect_agent_inner(
llm_config,
bank_id,
query,
bank_profile,
search_mental_models_fn,
search_observations_fn,
recall_fn,
expand_fn,
reflect_id=reflect_id,
provider_impl=provider_impl,
incremental_caching=incremental_caching,
cache_session_id=cache_session_id,
cache_tasks=cache_tasks,
**kwargs,
)
finally:
if incremental_caching and provider_impl is not None:
_spawn_cache_cleanup(provider_impl, cache_session_id, cache_tasks, reflect_id)
async def _run_reflect_agent_inner(
llm_config: "LLMProvider",
bank_id: str,
query: str,
@@ -434,6 +420,13 @@ async def run_reflect_agent(
max_context_tokens: int = 100_000,
llm_output_language: str | None = None,
cancel_check: Callable[[], None] | None = None,
store_document_text: bool = True,
*,
reflect_id: str,
provider_impl: Any,
incremental_caching: bool,
cache_session_id: str,
cache_tasks: list[asyncio.Task],
) -> ReflectAgentResult:
"""
Execute the reflect agent loop using native tool calling.
@@ -461,7 +454,6 @@ async def run_reflect_agent(
Returns:
ReflectAgentResult with final answer and metadata
"""
reflect_id = f"{bank_id[:8]}-{int(time.time() * 1000) % 100000}"
start_time = time.time()
# Build directives_applied for the trace
@@ -472,8 +464,8 @@ async def run_reflect_agent(
# Get tools for this agent (with directive compliance field if directives exist).
# The expand tool only reads back raw source text (chunks/documents), so it is
# useless and excluded when document text storage is disabled.
include_expand = get_config().store_document_text
# useless and excluded when document text storage is disabled (per bank).
include_expand = store_document_text
tools = get_reflect_tools(
directive_rules=directive_rules,
include_mental_models=has_mental_models,
@@ -498,30 +490,75 @@ 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():
# Step-by-step context caching for the agentic tool loop.
#
# Caching only the static system+tools prefix wins little here: it's dwarfed
# by the tool results (recall/observations) that get re-sent on every turn.
# Instead we roll a cache forward one step at a time — after each turn the
# cache is extended to cover that turn's FULL input, so the next ``auto`` turn
# reuses the entire prior conversation at the cached rate and sends only its
# own new tool results as the delta. Each new tool payload is therefore billed
# at full price exactly once (the turn it's produced), then cached thereafter.
#
# The cache create for turn N+1 covers turn N's input, which is fully known the
# moment turn N's LLM call returns — so we kick it off as a background task that
# runs CONCURRENTLY with turn N's tool execution (``_schedule_cache``) and only
# await it (``_resolve_pending_cache``) right before the next ``auto`` call,
# hiding the create latency behind work we'd do anyway.
#
# ``rolling_cache_boundary`` is the number of leading ``messages`` baked into
# the adopted ``rolling_cache_name``. ``incremental_caching`` is False for
# providers/config without explicit caching, so every branch below is a no-op.
rolling_cache_name: str | None = None
rolling_cache_boundary = 0
pending_cache_task: asyncio.Task | None = None
pending_cache_boundary = 0
async def _resolve_pending_cache() -> None:
"""Adopt the overlapped next-cache once it's ready as the rolling cache.
Best-effort: a failed/``None`` create just leaves the previous (smaller)
cache in place, so the next call sends a larger delta but stays correct.
"""
nonlocal rolling_cache_name, rolling_cache_boundary, pending_cache_task
if pending_cache_task is None:
return
task = pending_cache_task
pending_cache_task = None
try:
cached_prefix_name = await provider_impl.get_or_create_cached_prefix(
system_instruction=system_prompt,
tools=tools,
new_name = await task
except Exception:
new_name = None
if new_name is not None:
rolling_cache_name = new_name
rolling_cache_boundary = pending_cache_boundary
def _schedule_cache(upto: int) -> None:
"""Start building the cache covering ``messages[:upto]`` in the background
so it overlaps the tool execution that follows this turn."""
nonlocal pending_cache_task, pending_cache_boundary
# ``messages[:upto]`` is snapshotted now, so appends during tool execution
# can't change what gets cached. ``ensure_future`` raises if the provider
# didn't return a coroutine (e.g. a test double) — caching is a soft
# optimisation and must never break a reflect, so swallow and skip.
try:
task = asyncio.ensure_future(
provider_impl.create_incremental_cache(
session_id=cache_session_id, messages=messages[:upto], tools=tools
)
)
except Exception:
# Caching is a soft optimisation; never let a cache-side
# error block a reflect.
cached_prefix_name = None
return
pending_cache_boundary = upto
pending_cache_task = task
cache_tasks.append(task)
# Tracking
total_tools_called = 0
# Whether the model has ever produced a tool call reflect could understand.
# Stays False when a transport silently strips tool support (the model then
# only ever returns free text) -- that case fails via ReflectToolCallError.
saw_tool_call = False
tool_trace: list[ToolCall] = []
tool_trace_summary: list[dict[str, Any]] = []
llm_trace: list[dict[str, Any]] = []
@@ -635,12 +672,12 @@ async def run_reflect_agent(
"output_tokens": usage.output_tokens,
}
)
answer = _clean_answer_text(response.strip())
answer = response.strip()
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id, max_tokens)
structured_output = struct.structured_output
total_input_tokens += struct.input_tokens
total_output_tokens += struct.output_tokens
@@ -700,11 +737,11 @@ async def run_reflect_agent(
"output_tokens": usage.output_tokens,
}
)
answer = _clean_answer_text(response.strip())
answer = response.strip()
structured_output = None
if response_schema and answer:
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id, max_tokens)
structured_output = struct.structured_output
total_input_tokens += struct.input_tokens
total_output_tokens += struct.output_tokens
@@ -739,12 +776,35 @@ async def run_reflect_agent(
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"
iter_tool_choice = LLM_TOOL_CHOICE_AUTO
elif iteration < len(forced_sequence):
iter_tool_choice = {"type": "function", "function": {"name": forced_sequence[iteration]}}
iter_tool_choice = LLMToolChoice.named(forced_sequence[iteration])
else:
iter_tool_choice = "auto"
iter_tool_choice = LLM_TOOL_CHOICE_AUTO
# Will the NEXT turn be an ``auto`` turn (the only kind that references a
# cache)? The cache we schedule this turn covers this turn's input and is
# used by the next turn, so we only bother building it when the next turn
# can use it — skipping the wasted creates between two forced turns.
next_iter = iteration + 1
if stop_forcing_from_iteration is not None and next_iter >= stop_forcing_from_iteration:
next_is_auto = True
elif next_iter < len(forced_sequence):
next_is_auto = False
else:
next_is_auto = True
# Before an ``auto`` turn, adopt the cache that was being built in the
# background during the previous turn's tool execution. It covers that
# turn's full input, so THIS call reuses the entire prior conversation at
# the cached rate and sends only the turns appended since. Forced turns
# can't use a cache (Gemini rejects ``cached_content`` + ``tool_config``),
# but the cache still advances underneath them, so the first ``auto`` turn
# inherits a cache covering all the forced results.
if incremental_caching and iter_tool_choice is LLM_TOOL_CHOICE_AUTO:
await _resolve_pending_cache()
call_msg_count = len(messages)
try:
ct_kwargs: dict[str, Any] = dict(
messages=messages,
@@ -752,15 +812,9 @@ async def run_reflect_agent(
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
if incremental_caching and iter_tool_choice is LLM_TOOL_CHOICE_AUTO and rolling_cache_name is not None:
ct_kwargs["cached_prefix"] = rolling_cache_name
ct_kwargs["cached_prefix_message_count"] = rolling_cache_boundary
result = await llm_config.call_with_tools(**ct_kwargs)
llm_duration = int((time.time() - llm_start) * 1000)
consecutive_errors = 0
@@ -826,12 +880,12 @@ async def run_reflect_agent(
"output_tokens": usage.output_tokens,
}
)
answer = _clean_answer_text(response.strip())
answer = response.strip()
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id, max_tokens)
structured_output = struct.structured_output
total_input_tokens += struct.input_tokens
total_output_tokens += struct.output_tokens
@@ -850,83 +904,29 @@ async def run_reflect_agent(
directives_applied=directives_applied,
)
# No tool calls - LLM wants to respond with text
# No tool calls this turn.
if not result.tool_calls:
# When directives are present but no evidence has been gathered,
# the LLM tends to echo directive content verbatim as its answer.
# Fall through to the final-prompt path which doesn't include
# directives and handles "no data" gracefully.
has_gathered_evidence = (
bool(available_memory_ids) or bool(available_mental_model_ids) or bool(available_observation_ids)
)
directive_leak_risk = directives and not has_gathered_evidence
if result.content and not directive_leak_risk:
answer = _clean_answer_text(result.content.strip())
# The call_with_tools call above is intentionally uncapped so the
# LLM has headroom to emit tool-call JSON plus any intermediate
# reasoning. But when the LLM short-circuits and returns text
# directly, that text becomes the user-visible final answer and
# must respect max_tokens like the forced-final paths do. If it
# overshoots, run one extra capped call to rewrite it within
# the cap.
if max_tokens is not None and count_cl100k_tokens(answer) > max_tokens:
rewrite_start = time.time()
rewritten, rewrite_usage = await llm_config.call(
messages=[
{
"role": "system",
"content": (
"Rewrite the user's text so it fits within the requested token "
"budget. Preserve the key facts and structure; drop lower-priority "
"detail. Respond with the rewritten text only, no preamble."
),
},
{
"role": "user",
"content": f"Target budget: {max_tokens} tokens.\n\nText to rewrite:\n{answer}",
},
],
scope="reflect",
max_completion_tokens=max_tokens,
return_usage=True,
)
total_input_tokens += rewrite_usage.input_tokens
total_output_tokens += rewrite_usage.output_tokens
total_cached_tokens += getattr(rewrite_usage, "cached_tokens", 0) or 0
total_thoughts_tokens += getattr(rewrite_usage, "thoughts_tokens", 0) or 0
llm_trace.append(
{
"scope": "final_rewrite",
"duration_ms": int((time.time() - rewrite_start) * 1000),
"input_tokens": rewrite_usage.input_tokens,
"output_tokens": rewrite_usage.output_tokens,
}
)
answer = _clean_answer_text(rewritten.strip())
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
structured_output = struct.structured_output
total_input_tokens += struct.input_tokens
total_output_tokens += struct.output_tokens
total_cached_tokens += struct.cached_tokens
total_thoughts_tokens += struct.thoughts_tokens
_log_completion(answer, iteration + 1)
return ReflectAgentResult(
text=answer,
structured_output=structured_output,
iterations=iteration + 1,
tools_called=total_tools_called,
tool_trace=tool_trace,
llm_trace=_get_llm_trace(),
usage=_get_usage(),
directives_applied=directives_applied,
# Reflect is driven by structured tool calls. A turn with no tool call
# means one of two things:
# * the model already gathered evidence via earlier tool calls and is
# now stopping -- fine, synthesize a clean final answer below;
# * the transport can't produce tool calls at all, so it only ever
# returns free text (e.g. litellm strips tools on the Vertex gpt-oss
# MaaS path). In that case ``saw_tool_call`` is still False.
# We no longer salvage that free text as the answer -- it can be a raw
# done()-payload with sibling id fields leaking into user-visible text.
# Fail loudly instead so the caller picks a tool-calling-capable model.
if not saw_tool_call:
snippet = (result.content or "").strip()
if len(snippet) > 500:
snippet = snippet[:500] + "..."
detail = f" Response: {snippet!r}" if snippet else " The model returned no content."
raise ReflectToolCallError(
f"Reflect requires a tool-calling model, but {llm_config.provider}/{llm_config.model} "
f"produced no usable tool call (the transport may not support function calling)." + detail
)
# Empty response, force final
# Model tool-called earlier and is now stopping: fall through to a clean
# forced final synthesis (tools disabled, prose expected).
prompt = build_final_prompt(
query, context_history, bank_profile, context, max_context_tokens=max_context_tokens
)
@@ -958,12 +958,12 @@ async def run_reflect_agent(
"output_tokens": usage.output_tokens,
}
)
answer = _clean_answer_text(response.strip())
answer = response.strip()
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id, max_tokens)
structured_output = struct.structured_output
total_input_tokens += struct.input_tokens
total_output_tokens += struct.output_tokens
@@ -982,6 +982,11 @@ async def run_reflect_agent(
directives_applied=directives_applied,
)
# The model produced at least one tool call reflect could parse: it can
# drive the loop, so a later text-only turn is a legitimate stop, not a
# broken transport.
saw_tool_call = True
# Check for done tool call (handle various LLM output formats)
done_call = next((tc for tc in result.tool_calls if _is_done_tool(tc.name)), None)
if done_call:
@@ -1001,7 +1006,6 @@ async def run_reflect_agent(
{
"role": "tool",
"tool_call_id": done_call.id,
"name": done_call.name, # Required by Gemini
"content": json.dumps(
{
"error": "You must search for information first. Use search_mental_models(), search_observations(), or recall() before providing your final answer."
@@ -1035,6 +1039,7 @@ async def run_reflect_agent(
directives_applied=directives_applied,
llm_config=llm_config,
response_schema=response_schema,
max_tokens=max_tokens,
)
# Execute other tools in parallel (exclude done tool in all its format variants)
@@ -1066,7 +1071,6 @@ async def run_reflect_agent(
{
"role": "tool",
"tool_call_id": tc.id,
"name": tc.name,
"content": json.dumps(
{
"error": f"Tool '{_normalize_tool_name(tc.name)}' is not available. Use only the tools provided to you."
@@ -1078,6 +1082,16 @@ async def run_reflect_agent(
other_tools = allowed_tools
# Kick off the next-turn cache (covering THIS call's input) so it
# builds concurrently with the tool execution below — hiding the
# create latency. Only schedule when the next turn is ``auto`` (the
# only kind that references it); the next turn's pre-call resolve then
# adopts it. Resolve any prior in-flight create first so we don't drop
# its handle.
if incremental_caching and next_is_auto:
await _resolve_pending_cache()
_schedule_cache(call_msg_count)
# Execute tools in parallel
tool_tasks = [
_execute_tool_with_timing(
@@ -1160,7 +1174,6 @@ async def run_reflect_agent(
{
"role": "tool",
"tool_call_id": tc.id,
"name": tc.name, # Required by Gemini
"content": json.dumps(output, default=str, ensure_ascii=False),
}
)
@@ -1244,15 +1257,56 @@ async def _process_done_tool(
directives_applied: list[DirectiveInfo],
llm_config: "LLMProvider | None" = None,
response_schema: dict | None = None,
max_tokens: int | None = None,
) -> ReflectAgentResult:
"""Process the done tool call and return the result."""
args = done_call.arguments
# Extract and clean the answer - some LLMs leak structured output into the answer text
raw_answer = args.get("answer", "").strip()
answer = _clean_done_answer(raw_answer) if raw_answer else ""
# ``done`` is a structured tool call: trust its ``answer`` field verbatim.
# Sibling id fields (memory_ids, ...) live in their own arguments and are
# validated separately below -- they can't bleed into a parsed answer string.
answer = args.get("answer", "").strip()
if not answer:
answer = "No answer provided."
answer = NO_ANSWER_TEXT
final_usage = usage
if llm_config and max_tokens is not None and count_cl100k_tokens(answer) > max_tokens:
rewrite_start = time.time()
rewritten, rewrite_usage = await llm_config.call(
messages=[
{
"role": "system",
"content": (
"Rewrite the user's text so it fits within the requested token budget. "
"Preserve the key facts and structure; drop lower-priority detail. "
"Respond with the rewritten text only, no preamble."
),
},
{
"role": "user",
"content": f"Target budget: {max_tokens} tokens.\n\nText to rewrite:\n{answer}",
},
],
scope="reflect",
max_completion_tokens=max_tokens,
return_usage=True,
)
answer = rewritten.strip()
final_usage = TokenUsageSummary(
input_tokens=usage.input_tokens + rewrite_usage.input_tokens,
output_tokens=usage.output_tokens + rewrite_usage.output_tokens,
total_tokens=usage.total_tokens + rewrite_usage.input_tokens + rewrite_usage.output_tokens,
cached_tokens=usage.cached_tokens + (getattr(rewrite_usage, "cached_tokens", 0) or 0),
thoughts_tokens=usage.thoughts_tokens + (getattr(rewrite_usage, "thoughts_tokens", 0) or 0),
)
llm_trace.append(
LLMCall(
scope="final_rewrite",
duration_ms=int((time.time() - rewrite_start) * 1000),
input_tokens=rewrite_usage.input_tokens,
output_tokens=rewrite_usage.output_tokens,
)
)
# Validate IDs (only include IDs that were actually retrieved)
used_memory_ids = [mid for mid in (args.get("memory_ids") or []) if mid in available_memory_ids]
@@ -1261,17 +1315,16 @@ async def _process_done_tool(
# Generate structured output if schema provided
structured_output = None
final_usage = usage
if response_schema and llm_config and answer:
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id, max_tokens)
structured_output = struct.structured_output
# Add structured output tokens to usage
final_usage = TokenUsageSummary(
input_tokens=usage.input_tokens + struct.input_tokens,
output_tokens=usage.output_tokens + struct.output_tokens,
total_tokens=usage.total_tokens + struct.input_tokens + struct.output_tokens,
cached_tokens=usage.cached_tokens + struct.cached_tokens,
thoughts_tokens=usage.thoughts_tokens + struct.thoughts_tokens,
input_tokens=final_usage.input_tokens + struct.input_tokens,
output_tokens=final_usage.output_tokens + struct.output_tokens,
total_tokens=final_usage.total_tokens + struct.input_tokens + struct.output_tokens,
cached_tokens=final_usage.cached_tokens + struct.cached_tokens,
thoughts_tokens=final_usage.thoughts_tokens + struct.thoughts_tokens,
)
log_completion(answer, iterations)
@@ -1386,22 +1439,35 @@ async def _execute_tool(
query = args.get("query")
if not query:
return {"error": "search_mental_models requires a query parameter"}
max_results = int(args.get("max_results") or 5)
max_results, error = _parse_tool_int_arg_or_error(args, "max_results", default=5)
if error:
return {"error": error}
return await search_mental_models_fn(query, max_results)
elif tool_name == "search_observations":
query = args.get("query")
if not query:
return {"error": "search_observations requires a query parameter"}
max_tokens = max(int(args.get("max_tokens") or 5000), 1000) # Default 5000, min 1000
max_tokens, error = _parse_tool_int_arg_or_error(args, "max_tokens", default=5000, minimum=1000)
if error:
return {"error": error}
return await search_observations_fn(query, max_tokens)
elif tool_name == "recall":
query = args.get("query")
if not query:
return {"error": "recall requires a query parameter"}
max_tokens = max(int(args.get("max_tokens") or 2048), 1000) # Default 2048, min 1000
max_chunk_tokens = max(int(args.get("max_chunk_tokens") or 1000), 1000) # Always enabled, min 1000
max_tokens, error = _parse_tool_int_arg_or_error(args, "max_tokens", default=2048, minimum=1000)
if error:
return {"error": error}
max_chunk_tokens, error = _parse_tool_int_arg_or_error(
args,
"max_chunk_tokens",
default=1000,
minimum=1000,
)
if error:
return {"error": error}
return await recall_fn(query, max_tokens, max_chunk_tokens)
elif tool_name == "expand":
@@ -1415,23 +1481,63 @@ async def _execute_tool(
return {"error": f"Unknown tool: {tool_name}"}
_NULLISH_TOOL_INT_STRINGS = {"", "none", "null"}
def _parse_tool_int_arg(args: dict[str, Any], key: str, *, default: int, minimum: int | None = None) -> int:
raw_value = args.get(key)
if not raw_value:
value = default
elif isinstance(raw_value, str) and raw_value.strip().lower() in _NULLISH_TOOL_INT_STRINGS:
value = default
else:
value = int(raw_value)
if minimum is None:
return value
return max(value, minimum)
def _parse_tool_int_arg_or_error(
args: dict[str, Any],
key: str,
*,
default: int,
minimum: int | None = None,
) -> tuple[int, str | None]:
try:
return _parse_tool_int_arg(args, key, default=default, minimum=minimum), None
except (OverflowError, TypeError, ValueError):
return default, f"{key} must be an integer or null-like value"
def _summarize_tool_int_arg(args: dict[str, Any], key: str, *, default: int, minimum: int | None = None) -> str:
try:
return str(_parse_tool_int_arg(args, key, default=default, minimum=minimum))
except (OverflowError, TypeError, ValueError):
return f"invalid:{args.get(key)!r}"
def _summarize_tool_query(args: dict[str, Any]) -> str:
query = args.get("query") or ""
if not isinstance(query, str):
query = str(query)
return f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
def _summarize_input(tool_name: str, args: dict[str, Any]) -> str:
"""Create a summary of tool input for logging, showing all params."""
if tool_name == "search_mental_models":
query = args.get("query", "")
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
max_results = int(args.get("max_results") or 5)
query_preview = _summarize_tool_query(args)
max_results = _summarize_tool_int_arg(args, "max_results", default=5)
return f"(query={query_preview}, max_results={max_results})"
elif tool_name == "search_observations":
query = args.get("query", "")
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
max_tokens = max(int(args.get("max_tokens") or 5000), 1000)
query_preview = _summarize_tool_query(args)
max_tokens = _summarize_tool_int_arg(args, "max_tokens", default=5000, minimum=1000)
return f"(query={query_preview}, max_tokens={max_tokens})"
elif tool_name == "recall":
query = args.get("query", "")
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
max_tokens = max(int(args.get("max_tokens") or 2048), 1000)
max_chunk_tokens = max(int(args.get("max_chunk_tokens") or 1000), 1000)
query_preview = _summarize_tool_query(args)
max_tokens = _summarize_tool_int_arg(args, "max_tokens", default=2048, minimum=1000)
max_chunk_tokens = _summarize_tool_int_arg(args, "max_chunk_tokens", default=1000, minimum=1000)
return f"(query={query_preview}, max_tokens={max_tokens}, max_chunk_tokens={max_chunk_tokens})"
elif tool_name == "expand":
memory_ids = args.get("memory_ids", [])
@@ -177,20 +177,17 @@ _HEADING_RX = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
_BULLET_RX = re.compile(r"^\s*[-*+]\s+(.*)$")
_ORDERED_RX = re.compile(r"^\s*\d+[.)]\s+(.*)$")
_FENCE_RX = re.compile(r"^```([A-Za-z0-9_+-]*)\s*$")
def _strip_separators(lines: list[str]) -> list[str]:
"""Drop horizontal-rule lines (`---`, `***`) used as section separators.
Our renderer never emits these, but LLM output frequently includes them
between sections; treating them as blank lines avoids parsing them as
paragraphs.
"""
return ["" if re.fullmatch(r"\s*([-*_])\1{2,}\s*", line) else line for line in lines]
_SEPARATOR_RX = re.compile(r"\s*([-*_])\1{2,}\s*")
def _split_blocks(lines: list[str]) -> list[list[str]]:
"""Group consecutive non-blank lines into block chunks."""
"""Group consecutive non-blank lines into block chunks.
Horizontal-rule lines (`---`, `***`) count as blank. Our renderer never
emits these, but LLM output frequently includes them between sections;
treating them as blank avoids parsing them as paragraphs. Inside a fence
they are code, not a separator, so they are kept verbatim.
"""
chunks: list[list[str]] = []
current: list[str] = []
in_fence = False
@@ -202,7 +199,7 @@ def _split_blocks(lines: list[str]) -> list[list[str]]:
if in_fence:
current.append(line)
continue
if line.strip() == "":
if line.strip() == "" or _SEPARATOR_RX.fullmatch(line):
if current:
chunks.append(current)
current = []
@@ -250,8 +247,7 @@ def parse_markdown(markdown: str) -> StructuredDocument:
so we never silently drop user content. Section IDs are unique slugs of
their headings.
"""
raw_lines = (markdown or "").splitlines()
lines = _strip_separators(raw_lines)
lines = (markdown or "").splitlines()
sections: list[Section] = []
used_ids: set[str] = set()
@@ -328,18 +328,21 @@ async def tool_expand(
if not memory_ids:
return {"error": "memory_ids is required and must not be empty"}
# Validate and convert UUIDs
valid_uuids: list[uuid.UUID] = []
# Validate and convert UUIDs. Each id keeps a handle on its own UUID: a list of
# only the valid ones no longer lines up with memory_ids once one id is invalid.
uuid_by_id: dict[str, uuid.UUID] = {}
errors: dict[str, str] = {}
for mid in memory_ids:
try:
valid_uuids.append(uuid.UUID(mid))
uuid_by_id[mid] = uuid.UUID(mid)
except ValueError:
errors[mid] = f"Invalid memory_id format: {mid}"
if not valid_uuids:
if not uuid_by_id:
return {"error": "No valid memory IDs provided", "details": errors}
valid_uuids = list(uuid_by_id.values())
# Batch fetch all memory units
memories = await conn.fetch(
f"""
@@ -395,12 +398,12 @@ async def tool_expand(
# Build results
results: list[dict[str, Any]] = []
for mid, mem_uuid in zip(memory_ids, valid_uuids):
for mid in memory_ids:
if mid in errors:
results.append({"memory_id": mid, "error": errors[mid]})
continue
memory = memory_map.get(mem_uuid)
memory = memory_map.get(uuid_by_id[mid])
if not memory:
results.append({"memory_id": mid, "error": f"Memory not found: {mid}"})
continue
@@ -255,13 +255,20 @@ class MemoryFact(BaseModel):
@field_validator("metadata", mode="before")
@classmethod
def parse_metadata(cls, v: Any) -> dict[str, str] | None:
"""Parse metadata from JSON string if needed (asyncpg may return JSONB as str)."""
"""Parse metadata from JSON string if needed (asyncpg may return JSONB as str).
Also coerces non-string dict values (e.g., integer IDs stored in JSONB)
to strings, preventing ValidationError when consolidation encounters
metadata like {"original_id": 348} instead of {"original_id": "348"}.
"""
if v is None:
return None
if isinstance(v, str):
import json
return json.loads(v)
v = json.loads(v)
if isinstance(v, dict):
return {str(k): str(val) for k, val in v.items()}
return v
chunk_id: str | None = Field(
@@ -12,7 +12,7 @@ from pydantic import BaseModel, Field
from ..._vector_index import index_using_clause, uses_per_bank_vector_indexes
from ...config import get_config
from ..db_utils import acquire_with_retry
from ..db_utils import acquire_with_retry, retry_with_backoff
from ..memory_engine import fq_table, get_current_schema
from ..response_models import DispositionTraits
@@ -188,8 +188,19 @@ async def get_or_create_bank_profile(pool, bank_id: str) -> BankProfileResult:
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:
return await get_or_create_bank_profile_on_conn(conn, bank_id, ops=pool.ops)
# A fresh bank builds its per-(bank, fact_type) partial vector indexes with
# a plain CREATE INDEX (it must — this runs inside the bank-create tx, and
# CONCURRENTLY cannot). That CREATE takes a ShareLock on the shared
# memory_units table, which can deadlock with concurrent writers. The build
# is idempotent (INSERT ... ON CONFLICT + CREATE INDEX IF NOT EXISTS), so a
# transient deadlock (40P01 / ORA-00060) is safe to retry as a whole tx.
async def _create() -> BankProfileResult:
async with acquire_with_retry(pool) as conn:
async with conn.transaction():
return await get_or_create_bank_profile_on_conn(conn, bank_id, ops=pool.ops)
return await retry_with_backoff(_create)
async def get_or_create_bank_profile_on_conn(conn, bank_id: str, *, ops) -> BankProfileResult:
@@ -8,7 +8,7 @@ import hashlib
import logging
from dataclasses import dataclass
from ...config import get_config
from ...config import _get_raw_config
from ..memory_engine import fq_table
from .types import ChunkMetadata
@@ -64,14 +64,64 @@ async def delete_chunks_by_ids(conn, chunk_ids: list[str]) -> None:
"""
if not chunk_ids:
return
# PostgreSQL's FK cascade deletes child memory_links in executor-chosen
# order. Concurrent chunk deletes for the same bank can then lock overlapping
# memory_links in opposite orders and deadlock. Delete links explicitly in a
# total order before deleting chunks so every writer takes row locks the same
# way; the FK cascade still handles anything inserted later in this txn.
await conn.execute(
f"DELETE FROM {fq_table('chunks')} WHERE chunk_id = ANY($1::text[])",
f"""
WITH target_units AS MATERIALIZED (
SELECT id
FROM {fq_table("memory_units")}
WHERE chunk_id = ANY($1::text[])
),
ordered_links AS MATERIALIZED (
SELECT ml.ctid
FROM {fq_table("memory_links")} ml
WHERE EXISTS (
SELECT 1
FROM target_units tu
WHERE tu.id = ml.from_unit_id OR tu.id = ml.to_unit_id
)
ORDER BY
LEAST(ml.from_unit_id, ml.to_unit_id),
GREATEST(ml.from_unit_id, ml.to_unit_id),
ml.link_type,
COALESCE(ml.entity_id, '00000000-0000-0000-0000-000000000000'::uuid)
FOR UPDATE OF ml
)
DELETE FROM {fq_table("memory_links")} ml
USING ordered_links ol
WHERE ml.ctid = ol.ctid
""",
chunk_ids,
)
await conn.execute(
f"""
WITH ordered_chunks AS MATERIALIZED (
SELECT chunk_id
FROM {fq_table("chunks")}
WHERE chunk_id = ANY($1::text[])
ORDER BY chunk_id
FOR UPDATE
)
DELETE FROM {fq_table("chunks")} c
USING ordered_chunks oc
WHERE c.chunk_id = oc.chunk_id
""",
chunk_ids,
)
async def store_chunks_batch(
conn, bank_id: str, document_id: str, chunks: list[ChunkMetadata], ops=None
conn,
bank_id: str,
document_id: str,
chunks: list[ChunkMetadata],
ops=None,
store_document_text: bool | None = None,
) -> dict[int, str]:
"""
Store document chunks in the database.
@@ -82,6 +132,9 @@ async def store_chunks_batch(
document_id: Document identifier
chunks: List of ChunkMetadata objects
ops: DataAccessOps instance (from backend.ops)
store_document_text: Whether to persist raw chunk text. When ``None``,
falls back to the server-level default; callers on the retain path
pass the per-bank resolved value.
Returns:
Dictionary mapping global chunk index to chunk_id
@@ -92,7 +145,9 @@ async def store_chunks_batch(
# When document text storage is disabled, persist empty chunk_text (the
# column is NOT NULL) while still computing content_hash from the real text
# so delta-retain dedup is unaffected.
store_text = get_config().store_document_text
# Fallback to the raw global default (not get_config(), which guards
# bank-configurable fields); the retain path always passes the resolved value.
store_text = store_document_text if store_document_text is not None else _get_raw_config().store_document_text
# Prepare chunk data for batch insert
chunk_ids = []
@@ -7,7 +7,7 @@ Handles entity extraction and resolution for stored facts.
import logging
from . import link_utils
from .types import ProcessedFact
from .types import EntityResolutionResult, ProcessedFact
logger = logging.getLogger(__name__)
@@ -58,7 +58,7 @@ async def resolve_entities(
log_buffer: list[str] = None,
user_entities_per_content: dict[int, list[dict]] = None,
entity_labels: list | None = None,
) -> tuple[list[str], list[tuple], dict[str, list[str]]]:
) -> EntityResolutionResult:
"""
Phase 1: Resolve entity names to canonical IDs (read-heavy).
@@ -76,10 +76,10 @@ async def resolve_entities(
entity_labels: Optional entity label taxonomy
Returns:
Tuple of (resolved_entity_ids, entity_to_unit, unit_to_entity_ids).
EntityResolutionResult with the resolved identities and unit mappings.
"""
if not unit_ids or not facts:
return [], [], {}
return EntityResolutionResult(resolved_entities=[], entity_to_unit=[], unit_to_entity_ids={})
if len(unit_ids) != len(facts):
raise ValueError(f"Mismatch between unit_ids ({len(unit_ids)}) and facts ({len(facts)})")
@@ -15,9 +15,10 @@ from typing import Any, Literal, cast
from pydantic import BaseModel, ConfigDict, Field, create_model, field_validator
from ..llm_interface import ProviderRateLimitResetError
from ..llm_wrapper import LLMConfig, OutputTooLongError, sanitize_llm_output
from ..llm_wrapper import LLMConfig, OutputTooLongError, parse_llm_json, sanitize_llm_output
from ..operation_metadata import RetainExtractionErrors
from ..response_models import TokenUsage
from ..structured_output import strict_json_schema
from .entity_labels import (
EntityLabelsConfig,
MapField,
@@ -32,7 +33,7 @@ def _extract_map_entities(
entity_obj: dict,
fields: dict[str, MapField],
prefix: str,
validated_entities: "list[Entity]",
validated_entities: list[str],
existing_texts_lower: set[str],
) -> None:
"""Recursively extract key:field:value entity strings from a map entity dict."""
@@ -59,7 +60,7 @@ def _extract_map_entities(
continue
label_str = f"{prefix}{field_name}:{v.strip()}"
if label_str.lower() not in existing_texts_lower:
validated_entities.append(Entity(text=label_str))
validated_entities.append(label_str)
existing_texts_lower.add(label_str.lower())
else:
# text or value — single string
@@ -67,7 +68,7 @@ def _extract_map_entities(
continue
label_str = f"{prefix}{field_name}:{field_val.strip()}"
if label_str.lower() not in existing_texts_lower:
validated_entities.append(Entity(text=label_str))
validated_entities.append(label_str)
existing_texts_lower.add(label_str.lower())
@@ -114,12 +115,33 @@ def _sanitize_text(text: str | None) -> str | None:
return sanitize_llm_output(text)
class Entity(BaseModel):
"""An entity extracted from text."""
def _coerce_entity_strings(v: Any) -> Any:
"""
Normalize the LLM's `entities` field to a plain list of strings.
text: str = Field(
description="The specific, named entity as it appears in the fact. Must be a proper noun or specific identifier."
)
The schema previously asked for `Entity` objects ({"text": "..."}) while the
prompt's few-shot examples taught a flat string array. Models that followed
the examples literally returned strings, and the entities were silently
dropped none were ever persisted (#2749). The `Entity` wrapper carried no
information beyond the string, so it was removed rather than taught to the
prompt; the object form is still unwrapped here for models that learned it
and for in-flight batch jobs.
Returns non-list input untouched so pydantic reports the type error itself.
"""
if v is None:
return []
if not isinstance(v, list):
return v
coerced = []
for item in v:
if isinstance(item, dict):
text = item.get("text")
if isinstance(text, str):
coerced.append(text)
else:
coerced.append(item)
return coerced
class Fact(BaseModel):
@@ -144,7 +166,7 @@ class Fact(BaseModel):
)
# Optional structured data
entities: list[Entity] | None = None
entities: list[str] | None = None
causal_relations: list["CausalRelation"] | None = None
@@ -195,7 +217,9 @@ class ExtractedFact(BaseModel):
fact_type: Literal["world", "assistant"] = Field(
description="'world' = objective/external facts, including user preferences, rules, corrections, and constraints even when stated during a conversation. 'assistant' = actions, experiences, or observations the assistant/agent actually performed."
)
entities: list[Entity] | None = Field(default=None, description="People, places, concepts")
entities: list[str] = Field(
default_factory=list, description='People, places, concepts - plain strings, e.g. ["Alice", "Kubernetes"]'
)
causal_relations: list[FactCausalRelation] | None = Field(
default=None, description="Links to previous facts (target_index < this fact's index)"
)
@@ -203,10 +227,7 @@ class ExtractedFact(BaseModel):
@field_validator("entities", mode="before")
@classmethod
def ensure_entities_list(cls, v):
"""Ensure entities is always a list (convert None to empty list)."""
if v is None:
return []
return v
return _coerce_entity_strings(v)
def build_fact_text(self) -> str:
"""Combine all dimensions into a single comprehensive fact string."""
@@ -232,6 +253,55 @@ class FactExtractionResponse(BaseModel):
facts: list[ExtractedFact] = Field(description="List of extracted factual statements")
def _split_chunk_for_output_retry(chunk: str) -> tuple[str, str] | None:
"""Split an oversized extraction chunk without corrupting structured input."""
stripped = chunk.strip()
if len(stripped) <= 1:
return None
try:
parsed = json.loads(stripped)
except (TypeError, ValueError, json.JSONDecodeError):
parsed = None
if isinstance(parsed, list):
if len(parsed) >= 2:
mid = len(parsed) // 2
return json.dumps(parsed[:mid]), json.dumps(parsed[mid:])
if len(parsed) == 1 and isinstance(parsed[0], dict):
turn = parsed[0]
content = turn.get("content")
if isinstance(content, str) and len(content) > 1:
cut = len(content) // 2
first_turn = dict(turn)
second_turn = dict(turn)
first_turn["content"] = content[:cut]
second_turn["content"] = content[cut:]
return json.dumps([first_turn]), json.dumps([second_turn])
return None
# Split plain text at the midpoint, preferring sentence boundaries nearby.
mid_point = len(stripped) // 2
search_range = int(len(stripped) * 0.2)
search_start = max(0, mid_point - search_range)
search_end = min(len(stripped), mid_point + search_range)
best_split = mid_point
for ending in [". ", "! ", "? ", "\n\n"]:
pos = stripped.rfind(ending, search_start, search_end)
if pos != -1:
best_split = pos + len(ending)
break
first_half = stripped[:best_split].strip()
second_half = stripped[best_split:].strip()
if not first_half or not second_half or first_half == stripped or second_half == stripped:
return None
return first_half, second_half
class ExtractedFactVerbose(BaseModel):
"""A single extracted fact with verbose field descriptions for detailed extraction."""
@@ -299,9 +369,9 @@ class ExtractedFactVerbose(BaseModel):
description="'world' = objective/external facts about the user, other people, events, general knowledge, preferences, rules, corrections, or constraints. 'assistant' = actions, experiences, or observations the assistant/agent actually performed (e.g., 'I changed X', 'I discovered Y')."
)
entities: list[Entity] | None = Field(
default=None,
description="Named entities, objects, AND abstract concepts from the fact. Include: people names, organizations, places, significant objects (e.g., 'coffee maker', 'car'), AND abstract concepts/themes (e.g., 'friendship', 'career growth', 'loss', 'celebration'). Extract anything that could help link related facts together.",
entities: list[str] = Field(
default_factory=list,
description="Named entities, objects, AND abstract concepts from the fact, as plain strings (e.g. [\"Alice\", \"friendship\"]). Include: people names, organizations, places, significant objects (e.g., 'coffee maker', 'car'), AND abstract concepts/themes (e.g., 'friendship', 'career growth', 'loss', 'celebration'). Extract anything that could help link related facts together.",
)
causal_relations: list[FactCausalRelation] | None = Field(
@@ -313,9 +383,7 @@ class ExtractedFactVerbose(BaseModel):
@field_validator("entities", mode="before")
@classmethod
def ensure_entities_list(cls, v):
if v is None:
return []
return v
return _coerce_entity_strings(v)
class FactExtractionResponseVerbose(BaseModel):
@@ -348,17 +416,15 @@ class ExtractedFactNoCausal(BaseModel):
fact_type: Literal["world", "assistant"] = Field(
description="'world' = about the user/others, including user preferences, rules, corrections, and constraints. 'assistant' = actions or experiences the assistant/agent actually performed."
)
entities: list[Entity] | None = Field(
default=None,
description="Named entities, objects, and concepts from the fact.",
entities: list[str] = Field(
default_factory=list,
description='Named entities, objects, and concepts from the fact, as plain strings (e.g. ["Alice", "Kubernetes"]).',
)
@field_validator("entities", mode="before")
@classmethod
def ensure_entities_list(cls, v):
if v is None:
return []
return v
return _coerce_entity_strings(v)
class FactExtractionResponseNoCausal(BaseModel):
@@ -390,14 +456,14 @@ class VerbatimExtractedFact(BaseModel):
fact_type: Literal["world", "assistant"] = Field(
description="'world' = objective/external facts. 'assistant' = first-person actions, experiences, or observations by the speaker."
)
entities: list[Entity] | None = Field(default=None, description="People, places, concepts")
entities: list[str] = Field(
default_factory=list, description='People, places, concepts - plain strings, e.g. ["Alice", "Kubernetes"]'
)
@field_validator("entities", mode="before")
@classmethod
def ensure_entities_list(cls, v):
if v is None:
return []
return v
return _coerce_entity_strings(v)
class VerbatimFactExtractionResponse(BaseModel):
@@ -681,6 +747,11 @@ Use "Event Date" from input as reference for relative dates.
ENTITIES
ALWAYS return "entities" as an array of plain strings never objects, never null.
Correct: entities=["Alice", "Kubernetes", "CKA"]
Wrong: entities as an array of objects with a "text" key never use this form
Use an empty array [] only when the fact truly names nothing.
Include: people names, organizations, places, key objects, abstract concepts (career, friendship, etc.)
Always include "user" when fact is about the user.{examples}"""
@@ -1098,8 +1169,8 @@ def _build_extraction_prompt_and_schema(config) -> tuple[str, type]:
}
if not free_form_entities:
dynamic_fields["entities"] = (
list[Entity] | None,
Field(default=None, description="Leave empty — labels-only mode"),
list[str],
Field(default_factory=list, description="Leave empty — labels-only mode"),
)
# Inherit parent's required fields and add 'labels' so it appears in the JSON schema
# required array (the base class json_schema_extra overrides required entirely)
@@ -1223,19 +1294,32 @@ def _build_request_body(llm_config, config, prompt: str, user_message: str, resp
request_body["service_tier"] = llm_config._provider_impl.openai_service_tier
# 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.
# directly instead of going through LLMProvider.call(), so resolve the
# strict-schema flag here too: strict=True grammar-enforces the output on capable
# backends rather than relying on the model to emit clean JSON. Reads the
# retain-scoped field, which already folds in the global HINDSIGHT_API_LLM_STRICT_SCHEMA
# fallback, so the batch and streaming paths can't disagree.
if hasattr(response_schema, "model_json_schema"):
schema = response_schema.model_json_schema()
schema = (
strict_json_schema(response_schema) if config.llm_strict_schema else response_schema.model_json_schema()
)
request_body["response_format"] = {
"type": "json_schema",
"json_schema": {"name": "facts", "schema": schema, "strict": config.llm_strict_schema},
"json_schema": {"name": "facts", "schema": schema, "strict": config.llm_strict_schema_retain},
}
return request_body
def _coerce_fact_response(response: Any) -> dict[str, Any] | None:
"""Accept the schema wrapper, or a recoverable top-level facts array."""
if isinstance(response, dict):
return response
if isinstance(response, list) and all(isinstance(item, dict) for item in response):
return {"facts": response}
return None
async def _extract_facts_from_chunk(
chunk: str,
chunk_index: int,
@@ -1304,10 +1388,15 @@ async def _extract_facts_from_chunk(
llm_max_retries = (
config.retain_llm_max_retries if config.retain_llm_max_retries is not None else config.llm_max_retries
)
# OUTER content-validation attempts (re-prompts on malformed JSON). Follows the
# same `N + 1` convention as the providers' transport-retry loops — N retries after
# the initial request — so a zero budget still performs one request (#2731). The raw
# budget is forwarded unchanged to llm_config.call(), which owns transport retries.
outer_attempts = llm_max_retries + 1
last_error: Exception | None = None
usage = TokenUsage() # Track cumulative usage across retries
for attempt in range(llm_max_retries):
for attempt in range(outer_attempts):
try:
initial_backoff = (
config.retain_llm_initial_backoff
@@ -1323,6 +1412,7 @@ async def _extract_facts_from_chunk(
response_format=response_schema,
scope="retain_extract_facts",
temperature=config.llm_temperature_retain,
strict_schema=config.llm_strict_schema_retain,
max_completion_tokens=config.retain_max_completion_tokens,
max_retries=llm_max_retries,
initial_backoff=initial_backoff,
@@ -1341,10 +1431,11 @@ async def _extract_facts_from_chunk(
has_malformed_facts = False
# Handle malformed LLM responses
if not isinstance(extraction_response_json, dict):
if attempt < llm_max_retries - 1:
coerced_response_json = _coerce_fact_response(extraction_response_json)
if coerced_response_json is None:
if attempt < outer_attempts - 1:
logger.warning(
f"LLM returned non-dict JSON on attempt {attempt + 1}/{llm_max_retries}: {type(extraction_response_json).__name__}. Retrying..."
f"LLM returned non-dict JSON on attempt {attempt + 1}/{outer_attempts}: {type(extraction_response_json).__name__}. Retrying..."
)
continue
else:
@@ -1353,9 +1444,10 @@ async def _extract_facts_from_chunk(
# worker's retry machinery and ultimately fails loudly — never
# silently commit the document with 0 facts. See issue #1833.
raise RuntimeError(
f"Fact extraction failed: LLM returned non-dict JSON after {llm_max_retries} attempts "
f"Fact extraction failed: LLM returned non-dict JSON after {outer_attempts} attempts "
f"({type(extraction_response_json).__name__}). Raw: {str(extraction_response_json)[:500]}"
)
extraction_response_json = coerced_response_json
raw_facts = extraction_response_json.get("facts", [])
@@ -1392,6 +1484,8 @@ async def _extract_facts_from_chunk(
# Fallback to old format if new fields not present
if not what:
what = get_value("factual_core")
if not what:
what = get_value("text")
if not what:
# In verbatim mode, 'what' is intentionally absent — text is backfilled from chunk
if extraction_mode != "verbatim":
@@ -1451,21 +1545,9 @@ async def _extract_facts_from_chunk(
elif fact_data.get("occurred_start"):
fact_data["occurred_end"] = fact_data["occurred_start"]
# Add entities if present (validate as Entity objects)
# LLM sometimes returns strings instead of {"text": "..."} format
entities = get_value("entities")
validated_entities = []
if entities:
# Validate and normalize each entity
for ent in entities:
if isinstance(ent, str):
# Normalize string to Entity object
validated_entities.append(Entity(text=ent))
elif isinstance(ent, dict) and "text" in ent:
try:
validated_entities.append(Entity.model_validate(ent))
except Exception as e:
logger.warning(f"Invalid entity {ent}: {e}")
# Entities are plain strings. Older prompts taught a {"text": ...}
# object form, so keep unwrapping it for models that still emit it.
validated_entities = _coerce_entity_strings(get_value("entities"))
# Post-process label entities from structured labels object
entity_labels_raw = getattr(config, "entity_labels", None)
@@ -1475,7 +1557,7 @@ async def _extract_facts_from_chunk(
labels_lookup = build_labels_lookup(labels_cfg)
labels_data = llm_fact.get("labels") or {}
if isinstance(labels_data, dict):
existing_texts_lower = {e.text.lower() for e in validated_entities}
existing_texts_lower = {e.lower() for e in validated_entities}
for group in labels_cfg.attributes:
value = labels_data.get(group.key)
if not value:
@@ -1500,12 +1582,12 @@ async def _extract_facts_from_chunk(
label_str = f"{group.key}:{v.strip()}"
if group.type == "text":
if label_str.lower() not in existing_texts_lower:
validated_entities.append(Entity(text=label_str))
validated_entities.append(label_str)
existing_texts_lower.add(label_str.lower())
elif (
label_str.lower() in labels_lookup and label_str.lower() not in existing_texts_lower
):
validated_entities.append(Entity(text=label_str))
validated_entities.append(label_str)
existing_texts_lower.add(label_str.lower())
else:
logger.warning(f"Label '{label_str}' not in valid label values, skipping")
@@ -1513,7 +1595,7 @@ async def _extract_facts_from_chunk(
# In labels-only mode, keep only label entities
if not free_form_entities:
validated_entities = [
e for e in validated_entities if is_label_entity(e.text, labels_cfg, labels_lookup)
e for e in validated_entities if is_label_entity(e, labels_cfg, labels_lookup)
]
elif not free_form_entities:
# No labels but free_form disabled: clear all entities
@@ -1571,9 +1653,9 @@ async def _extract_facts_from_chunk(
continue
# If we got malformed facts and haven't exhausted retries, try again
if has_malformed_facts and len(chunk_facts) < len(raw_facts) * 0.8 and attempt < llm_max_retries - 1:
if has_malformed_facts and len(chunk_facts) < len(raw_facts) * 0.8 and attempt < outer_attempts - 1:
logger.warning(
f"Got {len(raw_facts) - len(chunk_facts)} malformed facts out of {len(raw_facts)} on attempt {attempt + 1}/{llm_max_retries}. Retrying..."
f"Got {len(raw_facts) - len(chunk_facts)} malformed facts out of {len(raw_facts)} on attempt {attempt + 1}/{outer_attempts}. Retrying..."
)
continue
@@ -1612,7 +1694,7 @@ async def _extract_facts_from_chunk(
# If we exhausted all retries, raise the last error or a descriptive fallback
if last_error is not None:
raise last_error
raise RuntimeError(f"Fact extraction failed after {llm_max_retries} attempts: LLM did not return valid JSON")
raise RuntimeError(f"Fact extraction failed after {outer_attempts} attempts: LLM did not return valid JSON")
async def _extract_facts_with_auto_split(
@@ -1664,33 +1746,22 @@ async def _extract_facts_with_auto_split(
metadata=metadata,
)
except OutputTooLongError:
# Output exceeded token limits - split the chunk in half and retry
# Output exceeded token limits - split the chunk and retry. Conversation
# chunks are JSON arrays, so preserve array/turn boundaries when possible.
logger.warning(
f"Output too long for chunk {chunk_index + 1}/{total_chunks} "
f"({len(chunk)} chars). Splitting in half and retrying..."
f"({len(chunk)} chars). Splitting and retrying..."
)
# Split at the midpoint, preferring sentence boundaries
mid_point = len(chunk) // 2
split_chunks = _split_chunk_for_output_retry(chunk)
if split_chunks is None:
logger.warning(
f"Cannot make progress splitting chunk {chunk_index + 1}/{total_chunks} "
f"({len(chunk)} chars); dropping this sub-chunk."
)
return [], TokenUsage()
# Try to find a sentence boundary near the midpoint
# Look for ". ", "! ", "? " within 20% of midpoint
search_range = int(len(chunk) * 0.2)
search_start = max(0, mid_point - search_range)
search_end = min(len(chunk), mid_point + search_range)
sentence_endings = [". ", "! ", "? ", "\n\n"]
best_split = mid_point
for ending in sentence_endings:
pos = chunk.rfind(ending, search_start, search_end)
if pos != -1:
best_split = pos + len(ending)
break
# Split the chunk
first_half = chunk[:best_split].strip()
second_half = chunk[best_split:].strip()
first_half, second_half = split_chunks
logger.info(
f"Split chunk {chunk_index + 1} into two sub-chunks: {len(first_half)} chars and {len(second_half)} chars"
@@ -2132,7 +2203,10 @@ async def extract_facts_from_contents_batch_api(
content_str = message.get("content", "{}")
try:
extraction_response_json = json.loads(content_str)
# #2701: use the lenient parser (strips markdown fences, scrubs
# embedded control chars) so recoverable batch responses — e.g.
# transient Gemini quirks — aren't dropped along with all their facts.
extraction_response_json = parse_llm_json(content_str)
except json.JSONDecodeError as e:
message = f"{custom_id}: failed to parse JSON: {e}"
logger.error(message)
@@ -2144,6 +2218,19 @@ async def extract_facts_from_contents_batch_api(
)
continue
response_type_name = type(extraction_response_json).__name__
extraction_response_json = _coerce_fact_response(extraction_response_json)
if extraction_response_json is None:
message = f"{custom_id}: LLM returned non-dict JSON ({response_type_name})"
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
)
)
continue
# Parse facts (reuse existing logic from _extract_facts_from_chunk)
raw_facts = extraction_response_json.get("facts", [])
chunk_facts = []
@@ -2161,6 +2248,8 @@ async def extract_facts_from_contents_batch_api(
what = get_value("what")
if not what:
what = get_value("factual_core")
if not what:
what = get_value("text")
if not what:
continue
@@ -2210,18 +2299,9 @@ async def extract_facts_from_contents_batch_api(
elif fact_data.get("occurred_start"):
fact_data["occurred_end"] = fact_data["occurred_start"]
# Entities
entities = get_value("entities")
validated_entities = []
if entities:
for ent in entities:
if isinstance(ent, str):
validated_entities.append(Entity(text=ent))
elif isinstance(ent, dict) and "text" in ent:
try:
validated_entities.append(Entity.model_validate(ent))
except Exception:
pass
# Entities are plain strings. Older prompts taught a {"text": ...}
# object form, so keep unwrapping it for models that still emit it.
validated_entities = _coerce_entity_strings(get_value("entities"))
# Post-process label entities from structured labels object
entity_labels_raw = getattr(config, "entity_labels", None)
@@ -2231,7 +2311,7 @@ async def extract_facts_from_contents_batch_api(
labels_lookup_batch = build_labels_lookup(labels_cfg_batch)
labels_data = llm_fact.get("labels") or {}
if isinstance(labels_data, dict):
existing_texts_lower = {e.text.lower() for e in validated_entities}
existing_texts_lower = {e.lower() for e in validated_entities}
for group in labels_cfg_batch.attributes:
value = labels_data.get(group.key)
if not value:
@@ -2256,18 +2336,18 @@ async def extract_facts_from_contents_batch_api(
label_str = f"{group.key}:{v.strip()}"
if group.type == "text":
if label_str.lower() not in existing_texts_lower:
validated_entities.append(Entity(text=label_str))
validated_entities.append(label_str)
existing_texts_lower.add(label_str.lower())
elif (
label_str.lower() in labels_lookup_batch
and label_str.lower() not in existing_texts_lower
):
validated_entities.append(Entity(text=label_str))
validated_entities.append(label_str)
existing_texts_lower.add(label_str.lower())
if not free_form_entities_batch:
validated_entities = [
e for e in validated_entities if is_label_entity(e.text, labels_cfg_batch, labels_lookup_batch)
e for e in validated_entities if is_label_entity(e, labels_cfg_batch, labels_lookup_batch)
]
elif not free_form_entities_batch:
validated_entities = []
@@ -2349,15 +2429,18 @@ async def extract_facts_from_contents_batch_api(
for chunk_meta, chunk_facts in facts_by_chunk:
content = contents[chunk_meta.content_index]
extraction_group_start_idx = global_fact_idx
for fact_from_llm in chunk_facts:
extracted_fact = ExtractedFactType(
fact_text=fact_from_llm.fact,
fact_type=fact_from_llm.fact_type,
entities=[e.text for e in (fact_from_llm.entities or [])],
entities=list(fact_from_llm.entities or []),
occurred_start=_parse_datetime(fact_from_llm.occurred_start) if fact_from_llm.occurred_start else None,
occurred_end=_parse_datetime(fact_from_llm.occurred_end) if fact_from_llm.occurred_end else None,
causal_relations=_convert_causal_relations(fact_from_llm.causal_relations or [], global_fact_idx),
causal_relations=_convert_causal_relations(
fact_from_llm.causal_relations or [], extraction_group_start_idx, len(chunk_facts)
),
content_index=chunk_meta.content_index,
chunk_index=chunk_meta.chunk_index,
context=content.context,
@@ -2541,40 +2624,37 @@ async def extract_facts_from_contents(
fact_idx_in_content = 0
for chunk_idx_in_content, (chunk_text, chunk_fact_count) in enumerate(chunks_from_llm):
chunk_global_idx = chunk_start_idx + chunk_idx_in_content
extraction_group_start_idx = global_fact_idx
chunk_facts = facts_from_llm[fact_idx_in_content : fact_idx_in_content + chunk_fact_count]
for _ in range(chunk_fact_count):
if fact_idx_in_content < len(facts_from_llm):
fact_from_llm = facts_from_llm[fact_idx_in_content]
for fact_from_llm in chunk_facts:
# Convert Fact model from LLM to ExtractedFactType dataclass
# mentioned_at is always the event_date (when the conversation/document occurred)
extracted_fact = ExtractedFactType(
fact_text=fact_from_llm.fact,
fact_type=fact_from_llm.fact_type,
entities=list(fact_from_llm.entities or []),
# occurred_start/end: from LLM only, leave None if not provided
occurred_start=_parse_datetime(fact_from_llm.occurred_start)
if fact_from_llm.occurred_start
else None,
occurred_end=_parse_datetime(fact_from_llm.occurred_end) if fact_from_llm.occurred_end else None,
causal_relations=_convert_causal_relations(
fact_from_llm.causal_relations or [], extraction_group_start_idx, len(chunk_facts)
),
content_index=content_index,
chunk_index=chunk_global_idx,
context=content.context,
# mentioned_at: always the event_date (when the conversation/document occurred)
mentioned_at=content.event_date,
metadata=content.metadata,
tags=content.tags,
observation_scopes=content.observation_scopes,
)
# Convert Fact model from LLM to ExtractedFactType dataclass
# mentioned_at is always the event_date (when the conversation/document occurred)
extracted_fact = ExtractedFactType(
fact_text=fact_from_llm.fact,
fact_type=fact_from_llm.fact_type,
entities=[e.text for e in (fact_from_llm.entities or [])],
# occurred_start/end: from LLM only, leave None if not provided
occurred_start=_parse_datetime(fact_from_llm.occurred_start)
if fact_from_llm.occurred_start
else None,
occurred_end=_parse_datetime(fact_from_llm.occurred_end)
if fact_from_llm.occurred_end
else None,
causal_relations=_convert_causal_relations(
fact_from_llm.causal_relations or [], global_fact_idx
),
content_index=content_index,
chunk_index=chunk_global_idx,
context=content.context,
# mentioned_at: always the event_date (when the conversation/document occurred)
mentioned_at=content.event_date,
metadata=content.metadata,
tags=content.tags,
observation_scopes=content.observation_scopes,
)
extracted_facts.append(extracted_fact)
global_fact_idx += 1
fact_idx_in_content += 1
extracted_facts.append(extracted_fact)
global_fact_idx += 1
fact_idx_in_content += 1
# Step 4: For verbatim mode, collapse to one fact per chunk with original text
if config.retain_extraction_mode == "verbatim":
@@ -2626,7 +2706,9 @@ def _parse_datetime(date_str: str):
return None
def _convert_causal_relations(relations_from_llm, fact_start_idx: int) -> list[CausalRelationType]:
def _convert_causal_relations(
relations_from_llm, extraction_group_start_idx: int, extraction_group_size: int
) -> list[CausalRelationType]:
"""
Convert causal relations from LLM format to ExtractedFact format.
@@ -2634,9 +2716,16 @@ def _convert_causal_relations(relations_from_llm, fact_start_idx: int) -> list[C
"""
causal_relations = []
for rel in relations_from_llm:
target_fact_index = rel.target_fact_index
if (
not isinstance(target_fact_index, int)
or isinstance(target_fact_index, bool)
or not 0 <= target_fact_index < extraction_group_size
):
continue
causal_relation = CausalRelationType(
relation_type=rel.relation_type,
target_fact_index=fact_start_idx + rel.target_fact_index,
target_fact_index=extraction_group_start_idx + target_fact_index,
)
causal_relations.append(causal_relation)
return causal_relations
@@ -9,7 +9,7 @@ import logging
import uuid
from datetime import datetime
from ...config import get_config
from ...config import _get_raw_config, get_config
from ..memory_engine import fq_table
from .bank_utils import DEFAULT_DISPOSITION, create_bank_vector_indexes
from .fact_extraction import _sanitize_text
@@ -271,6 +271,7 @@ async def handle_document_tracking(
retain_params: dict | None = None,
document_tags: list[str] | None = None,
ops=None,
store_document_text: bool | None = None,
) -> None:
"""
Handle document tracking in the database (full-replace mode).
@@ -358,6 +359,7 @@ async def handle_document_tracking(
retain_params,
document_tags,
preserved_created_at=preserved_created_at,
store_document_text=store_document_text,
)
@@ -368,6 +370,7 @@ async def upsert_document_metadata(
combined_content: str,
retain_params: dict | None = None,
document_tags: list[str] | None = None,
store_document_text: bool | None = None,
) -> None:
"""
Update document metadata without deleting existing facts/chunks.
@@ -380,7 +383,16 @@ async def upsert_document_metadata(
combined_content = _sanitize_text(combined_content) or ""
content_hash = hashlib.sha256(combined_content.encode()).hexdigest()
await _upsert_document_row(conn, bank_id, document_id, combined_content, content_hash, retain_params, document_tags)
await _upsert_document_row(
conn,
bank_id,
document_id,
combined_content,
content_hash,
retain_params,
document_tags,
store_document_text=store_document_text,
)
async def _upsert_document_row(
@@ -392,6 +404,7 @@ async def _upsert_document_row(
retain_params: dict | None = None,
document_tags: list[str] | None = None,
preserved_created_at: datetime | None = None,
store_document_text: bool | None = None,
) -> None:
"""Insert or update a document row.
@@ -403,8 +416,13 @@ async def _upsert_document_row(
When ``store_document_text`` is disabled, the raw source text
is dropped and ``original_text`` is stored as NULL. The ``content_hash`` is
still computed from the real content so delta-retain dedup is unaffected.
``store_document_text`` defaults to the server-level config when ``None``;
the retain path passes the per-bank resolved value.
"""
original_text = combined_content if get_config().store_document_text else None
# Fallback to the raw global default (not get_config(), which guards
# bank-configurable fields); the retain path always passes the resolved value.
store_text = store_document_text if store_document_text is not None else _get_raw_config().store_document_text
original_text = combined_content if store_text else None
await conn.execute(
f"""
INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, retain_params, tags, created_at, updated_at)
@@ -37,6 +37,7 @@ async def create_semantic_links_batch(
bank_id: str,
unit_ids: list[str],
embeddings: list[list[float]],
threshold: float,
pre_computed_ann_links: list[tuple] | None = None,
ops=None,
) -> int:
@@ -52,6 +53,7 @@ async def create_semantic_links_batch(
bank_id: Bank identifier
unit_ids: List of unit IDs to create links for
embeddings: List of embedding vectors (same length as unit_ids)
threshold: Minimum cosine similarity for semantic links
pre_computed_ann_links: Pre-computed ANN results from Phase 1
Returns:
@@ -64,7 +66,14 @@ async def create_semantic_links_batch(
raise ValueError(f"Mismatch between unit_ids ({len(unit_ids)}) and embeddings ({len(embeddings)})")
return await link_utils.create_semantic_links_batch(
conn, bank_id, unit_ids, embeddings, log_buffer=[], pre_computed_ann_links=pre_computed_ann_links, ops=ops
conn,
bank_id,
unit_ids,
embeddings,
threshold=threshold,
log_buffer=[],
pre_computed_ann_links=pre_computed_ann_links,
ops=ops,
)
@@ -74,7 +83,9 @@ async def create_causal_links_batch(
"""
Create causal links between facts.
Links facts that have causal relationships (causes, enables, prevents).
Retain writes the canonical ``caused_by`` relationship only. The database and
retrieval paths also recognize historical causal types so imported and
pre-existing memories remain traversable.
Args:
conn: Database connection
@@ -90,22 +101,7 @@ async def create_causal_links_batch(
if len(unit_ids) != len(facts):
raise ValueError(f"Mismatch between unit_ids ({len(unit_ids)}) and facts ({len(facts)})")
# Extract causal relations in the format expected by link_utils
# Format: List of lists, where each inner list is the causal relations for that fact
causal_relations_per_fact = []
for fact in facts:
if fact.causal_relations:
# Convert CausalRelation objects to dicts
relations_dicts = [
{
"relation_type": rel.relation_type,
"target_fact_index": rel.target_fact_index,
}
for rel in fact.causal_relations
]
causal_relations_per_fact.append(relations_dicts)
else:
causal_relations_per_fact.append([])
causal_relations_per_fact = [fact.causal_relations or [] for fact in facts]
link_count = await link_utils.create_causal_links_batch(conn, bank_id, unit_ids, causal_relations_per_fact, ops=ops)
@@ -7,7 +7,17 @@ import time
from datetime import UTC, datetime, timedelta
from ..._vector_index import ann_search_tuning_settings, configured_vector_extension
from ..causal_links import (
CANONICAL_CAUSAL_LINK_TYPES,
CAUSAL_LINK_TYPES,
DEFAULT_CAUSAL_LINK_WEIGHT,
LEGACY_CAUSAL_LINK_TYPES,
CausalLinkDescriptor,
)
from ..db.base import DatabaseConnection
from ..db.ops import DataAccessOps
from ..memory_engine import fq_table
from .types import CausalRelation, EntityResolutionResult
logger = logging.getLogger(__name__)
@@ -300,7 +310,7 @@ async def resolve_entities_only(
llm_entities: list[list[dict]],
log_buffer: list[str] = None,
entity_labels: list | None = None,
) -> tuple[list[str], list[tuple], dict[str, list[str]]]:
) -> EntityResolutionResult:
"""
Phase 1 of entity processing: resolve entity names to canonical IDs.
@@ -321,10 +331,10 @@ async def resolve_entities_only(
entity_labels: Optional entity label taxonomy
Returns:
Tuple of (resolved_entity_ids, entity_to_unit, unit_to_entity_ids) where:
- resolved_entity_ids: list of entity IDs in same order as flattened entities
- entity_to_unit: maps flat index to (unit_id, local_index, fact_date)
- unit_to_entity_ids: maps unit_id to list of resolved entity IDs
EntityResolutionResult carrying the resolved entity identities (id +
stored canonical name, in flattened order), the flat-index unit map,
and the unit entity-id map used to remap placeholder unit IDs in
Phase 2.
"""
all_entities_flat, _all_entities, entity_to_unit = _prepare_entities_for_resolution(
unit_ids, sentences, fact_dates, llm_entities, log_buffer
@@ -332,10 +342,10 @@ async def resolve_entities_only(
if not all_entities_flat:
_log(log_buffer, " [6.2] Entity resolution (batched): 0 entities", level="debug")
return [], [], {}
return EntityResolutionResult(resolved_entities=[], entity_to_unit=[], unit_to_entity_ids={})
step_start = time.time()
resolved_entity_ids = await entity_resolver.resolve_entities_batch(
resolved_entities = await entity_resolver.resolve_entities_batch(
bank_id=bank_id,
entities_data=all_entities_flat,
context=context,
@@ -354,7 +364,7 @@ async def resolve_entities_only(
for idx, (unit_id, _local_idx, _fact_date) in enumerate(entity_to_unit):
if unit_id not in unit_to_entity_ids:
unit_to_entity_ids[unit_id] = []
unit_to_entity_ids[unit_id].append(resolved_entity_ids[idx])
unit_to_entity_ids[unit_id].append(resolved_entities[idx].entity_id)
_log(
log_buffer,
@@ -362,7 +372,11 @@ async def resolve_entities_only(
level="debug",
)
return resolved_entity_ids, entity_to_unit, unit_to_entity_ids
return EntityResolutionResult(
resolved_entities=resolved_entities,
entity_to_unit=entity_to_unit,
unit_to_entity_ids=unit_to_entity_ids,
)
async def create_temporal_links_batch_per_fact(
@@ -512,7 +526,8 @@ async def compute_semantic_links_ann(
embeddings: list[list[float]],
fact_types: list[str] | None = None,
top_k: int = 50,
threshold: float = 0.7,
*,
threshold: float,
log_buffer: list[str] = None,
) -> list[tuple]:
"""
@@ -653,12 +668,13 @@ def compute_semantic_links_within_batch(
unit_ids: list[str],
embeddings: list[list[float]],
top_k: int = 50,
threshold: float = 0.7,
*,
threshold: float,
) -> list[tuple]:
"""
Compute semantic links between units within the same batch (no DB needed).
Uses numpy dot product on embeddings already in memory instant.
Uses cosine similarity on embeddings already in memory instant.
Args:
unit_ids: Unit IDs (real IDs from insert_facts_batch)
@@ -675,15 +691,25 @@ def compute_semantic_links_within_batch(
import numpy as np
links = []
new_embeddings_matrix = np.array(embeddings)
new_embeddings_matrix = np.asarray(embeddings, dtype=float)
norms = np.linalg.norm(new_embeddings_matrix, axis=1)
valid_embeddings = np.isfinite(new_embeddings_matrix).all(axis=1) & np.isfinite(norms) & (norms > 0)
normalized_embeddings = np.zeros_like(new_embeddings_matrix)
normalized_embeddings[valid_embeddings] = (
new_embeddings_matrix[valid_embeddings] / norms[valid_embeddings, np.newaxis]
)
for i, unit_id in enumerate(unit_ids):
if not valid_embeddings[i]:
continue
other_indices = [j for j in range(len(unit_ids)) if j != i]
if not other_indices:
continue
other_embeddings = new_embeddings_matrix[other_indices]
similarities = np.dot(other_embeddings, new_embeddings_matrix[i])
other_embeddings = normalized_embeddings[other_indices]
similarities = np.dot(other_embeddings, normalized_embeddings[i])
similarities[~valid_embeddings[other_indices]] = -np.inf
above_threshold = np.where(similarities >= threshold)[0]
if len(above_threshold) > 0:
@@ -703,7 +729,8 @@ async def create_semantic_links_batch(
unit_ids: list[str],
embeddings: list[list[float]],
top_k: int = 50,
threshold: float = 0.7,
*,
threshold: float,
log_buffer: list[str] = None,
pre_computed_ann_links: list[tuple] | None = None,
ops=None,
@@ -738,7 +765,12 @@ async def create_semantic_links_batch(
# Within-batch similarities (numpy, no DB)
batch_start = time_mod.time()
within_batch_links = compute_semantic_links_within_batch(unit_ids, embeddings, top_k, threshold)
within_batch_links = compute_semantic_links_within_batch(
unit_ids,
embeddings,
top_k,
threshold=threshold,
)
all_links.extend(within_batch_links)
_log(
log_buffer,
@@ -771,28 +803,61 @@ async def create_semantic_links_batch(
async def create_causal_links_batch(
conn,
conn: DatabaseConnection,
bank_id: str,
unit_ids: list[str],
causal_relations_per_fact: list[list[dict]],
ops=None,
causal_relations_per_fact: list[list[CausalRelation]],
ops: DataAccessOps | None = None,
) -> int:
"""
Create causal links between facts based on LLM-extracted causal relationships.
"""Create canonical causal links for the retain pipeline.
Args:
conn: Database connection
unit_ids: List of unit IDs (in same order as causal_relations_per_fact)
causal_relations_per_fact: List of causal relations for each fact.
Each element is a list of dicts with:
- target_fact_index: Index into unit_ids for the target fact
- relation_type: "caused_by"
Retain must only create the backward-looking ``caused_by`` form. Historical
types are restored exclusively through ``restore_legacy_causal_links_batch``.
"""
return await _write_causal_links_batch(
conn,
bank_id,
unit_ids,
causal_relations_per_fact,
CANONICAL_CAUSAL_LINK_TYPES,
ops=ops,
)
async def restore_legacy_causal_links_batch(
conn: DatabaseConnection,
bank_id: str,
unit_ids: list[str],
causal_relations_per_fact: list[list[CausalRelation]],
ops: DataAccessOps | None = None,
) -> int:
"""Restore historical causal links while importing a transfer archive.
This is deliberately separate from the retain writer: retrieval continues
reading historical types, but only transfer import may create them.
"""
return await _write_causal_links_batch(
conn,
bank_id,
unit_ids,
causal_relations_per_fact,
LEGACY_CAUSAL_LINK_TYPES,
ops=ops,
)
async def _write_causal_links_batch(
conn: DatabaseConnection,
bank_id: str,
unit_ids: list[str],
causal_relations_per_fact: list[list[CausalRelation]],
allowed_relation_types: frozenset[str],
ops: DataAccessOps | None = None,
) -> int:
"""Write causal links after the caller has selected its allowed taxonomy.
Returns:
Number of causal links created
Causal link type:
- "caused_by": This fact was caused by the target fact
"""
if not unit_ids or not causal_relations_per_fact:
return 0
@@ -809,15 +874,13 @@ async def create_causal_links_batch(
from_unit_id = unit_ids[fact_idx]
for relation in causal_relations:
target_idx = relation["target_fact_index"]
relation_type = relation["relation_type"]
target_idx = relation.target_fact_index
relation_type = relation.relation_type
# Validate relation_type - only "caused_by" is supported (DB constraint)
valid_types = {"caused_by"}
if relation_type not in valid_types:
if relation_type not in allowed_relation_types:
logger.error(
f"Invalid relation_type '{relation_type}' (type: {type(relation_type).__name__}) "
f"from fact {fact_idx}. Must be one of: {valid_types}. "
f"from fact {fact_idx}. Must be one of: {allowed_relation_types}. "
f"Relation data: {relation}"
)
continue
@@ -848,3 +911,108 @@ async def create_causal_links_batch(
traceback.print_exc()
raise
async def snapshot_causal_links(conn: DatabaseConnection, bank_id: str, unit_id: str) -> list[CausalLinkDescriptor]:
"""Collect the causal edges that must survive a unit's move to the archive.
Causal edges are retain-time extraction output: unlike temporal/semantic
links they can't be recomputed from dates or embeddings, and nothing
rebuilds them (graph maintenance only relinks temporal/semantic, and
consolidation regenerates observations, not raw-fact edges). Invalidation
removes the live row, so the FK cascade takes every incident edge with it
hence this snapshot, parked on the archive row (#2864).
The snapshot merges two sources:
* the unit's currently materialized causal edges, and
* descriptors already parked on *archived* peers that name this unit an
edge whose other endpoint was invalidated first is no longer in
``memory_links``, so the peer's snapshot is the only copy left.
Keeping a copy on every archived endpoint makes revert order irrelevant:
whichever endpoint comes back last sees both sides live and rematerializes.
Returns:
The descriptors to store on the archive row (deduplicated across both
sources by the UNION).
"""
rows = await conn.fetch(
f"""
SELECT from_unit_id, to_unit_id, link_type, weight
FROM {fq_table("memory_links")}
WHERE (from_unit_id = $1 OR to_unit_id = $1)
AND bank_id = $2
AND link_type = ANY($3::text[])
UNION
SELECT d.from_unit_id, d.to_unit_id, d.link_type, d.weight
FROM {fq_table("invalidated_memory_units")} a
CROSS JOIN LATERAL jsonb_to_recordset(a.causal_links)
AS d(from_unit_id uuid, to_unit_id uuid, link_type text, weight float8)
WHERE a.bank_id = $2
AND a.causal_links <> '[]'::jsonb
AND (d.from_unit_id = $1 OR d.to_unit_id = $1)
-- Same guard as CausalLinkDescriptor.from_json_dict: the column is
-- schemaless JSON, and a malformed entry would otherwise be copied
-- forward as a NULL-endpoint descriptor.
AND d.from_unit_id IS NOT NULL
AND d.to_unit_id IS NOT NULL
AND d.link_type = ANY($3::text[])
""",
unit_id,
bank_id,
list(CAUSAL_LINK_TYPES),
)
return [
CausalLinkDescriptor(
from_unit_id=str(row["from_unit_id"]),
to_unit_id=str(row["to_unit_id"]),
link_type=row["link_type"],
weight=float(row["weight"]) if row["weight"] is not None else DEFAULT_CAUSAL_LINK_WEIGHT,
)
for row in rows
]
async def rematerialize_causal_links(
conn: DatabaseConnection,
bank_id: str,
stored_descriptors: list,
ops: DataAccessOps | None = None,
) -> int:
"""Recreate archived causal edges whose endpoints are both live again.
Counterpart of :func:`snapshot_causal_links`, called when a fact reverts to
``valid``. Descriptors whose peer is still archived (or was permanently
deleted) are silently dropped from this insert: the bulk writer only takes
links whose endpoints exist in ``memory_units``. That is the point a
still-archived peer keeps its own copy of the descriptor and materializes
the edge when *it* reverts.
Insertion is ``ON CONFLICT DO NOTHING``, so repeated invalidate/revert
cycles never duplicate an edge.
Args:
stored_descriptors: The archive row's ``causal_links`` payload, already
decoded from JSON. Entries that don't parse as a causal edge are
skipped (see :meth:`CausalLinkDescriptor.from_json_dict`).
Returns:
Number of descriptors submitted (not all of which may materialize).
"""
parsed = [CausalLinkDescriptor.from_json_dict(raw) for raw in stored_descriptors]
links = [
(
descriptor.from_unit_id,
descriptor.to_unit_id,
descriptor.link_type,
descriptor.weight,
None,
)
for descriptor in parsed
if descriptor is not None
]
if not links:
return 0
await _bulk_insert_links(conn, links, bank_id=bank_id, ops=ops)
return len(links)
@@ -71,6 +71,25 @@ def _redact_document_body(body: str, config: Any) -> str:
return apply_redaction(body).content
def _is_strict_append_of_stored_document(
stored_original_text: str | None,
document_body_override: str | None,
config: Any,
) -> bool:
"""Return whether an oversized document body strictly appends stored text.
``documents.original_text`` is sanitized and may also be Memory Defense
redacted before persistence. Apply those same transformations to the
complete incoming body before comparing it with the stored prefix.
"""
if stored_original_text is None or document_body_override is None:
return False
redacted_body = _redact_document_body(document_body_override, config)
sanitized_body = fact_extraction._sanitize_text(redacted_body) or ""
return len(sanitized_body) > len(stored_original_text) and sanitized_body.startswith(stored_original_text)
async def _fire_memory_defense_webhook(
webhook_manager: Any,
*,
@@ -143,7 +162,7 @@ async def _fire_memory_defense_webhook(
logger.warning("memory_defense webhook delivery failed", exc_info=True)
def _audit_memory_defense(
async def _audit_memory_defense(
audit_logger: Any,
*,
bank_id: str,
@@ -152,11 +171,15 @@ def _audit_memory_defense(
) -> None:
"""Write a fire-and-forget ``memory_defense`` audit entry for a non-allow decision.
No-op when audit logging is disabled (the logger gates on its own config).
No-op when auditing is off for this bank. ``audit_log_enabled`` is per-bank
overridable, so the decision must be awaited here rather than relying on the
logger's synchronous allowlist check alone.
The action taken (redact/block) and what matched live in the entry metadata.
"""
if audit_logger is None:
return
if not await audit_logger.should_log("memory_defense", bank_id):
return
from ..audit import AuditEntry
entry = AuditEntry(
@@ -246,10 +269,12 @@ from . import (
link_creation,
)
from .types import (
CausalRelation,
ChunkMetadata,
EntityResolutionResult,
ExtractedFact,
Phase1Result,
ProcessedFact,
ResolvedEntity,
RetainContent,
RetainContentDict,
)
@@ -260,6 +285,15 @@ RetainOutboxCallback = Callable[[asyncpg.Connection], Awaitable[None]]
RetainOutboxCallbackFactory = Callable[[list[RetainContentDict]], RetainOutboxCallback | None]
@dataclass
class _ProcessedFactBatch:
"""Aligned survivors from converting extracted facts for storage."""
extracted_facts: list[ExtractedFact]
processed_facts: list[ProcessedFact]
retained_index_by_original: list[int | None]
def _resolve_narrator(profile_name: str, bank_id: str) -> str | None:
"""Resolve the narrator (memory owner) used to prime fact extraction.
@@ -344,7 +378,7 @@ async def _pre_resolve_phase1(
embeddings = [fact.embedding for fact in processed_facts]
async with acquire_with_retry(pool) as resolve_conn:
resolved_entity_ids, entity_to_unit, unit_to_entity_ids = await entity_processing.resolve_entities(
entity_resolution = await entity_processing.resolve_entities(
entity_resolver,
resolve_conn,
bank_id,
@@ -362,15 +396,17 @@ async def _pre_resolve_phase1(
if not skip_semantic_ann:
fact_types = [fact.fact_type for fact in processed_facts]
semantic_ann_links = await compute_semantic_links_ann(
resolve_conn, bank_id, placeholder_unit_ids, embeddings, fact_types=fact_types, log_buffer=log_buffer
resolve_conn,
bank_id,
placeholder_unit_ids,
embeddings,
fact_types=fact_types,
threshold=config.semantic_link_min_similarity,
log_buffer=log_buffer,
)
return Phase1Result(
entities=EntityResolutionResult(
resolved_entity_ids=resolved_entity_ids,
entity_to_unit=entity_to_unit,
unit_to_entity_ids=unit_to_entity_ids,
),
entities=entity_resolution,
semantic_ann_links=semantic_ann_links,
)
@@ -421,7 +457,7 @@ async def _insert_facts_and_links(
processed_facts: list[ProcessedFact],
config,
log_buffer: list[str],
resolved_entity_ids: list[str],
resolved_entities: list[ResolvedEntity],
entity_to_unit: list[tuple],
unit_to_entity_ids: dict[str, list[str]],
semantic_ann_links: list[tuple],
@@ -448,6 +484,7 @@ async def _insert_facts_and_links(
# Entity resolution was done in Phase 1 (separate connection).
# Remap placeholder IDs to actual unit IDs.
step_start = time.time()
resolved_entity_ids = [entity.entity_id for entity in resolved_entities]
remapped_entity_to_unit, _remapped_unit_to_entity_ids, remapped_semantic = _remap_phase1_results(
resolved_entity_ids, entity_to_unit, unit_to_entity_ids, semantic_ann_links or [], unit_ids
)
@@ -460,6 +497,10 @@ async def _insert_facts_and_links(
(unit_id, resolved_entity_ids[idx], fact_date)
for idx, (unit_id, _local_idx, fact_date) in enumerate(remapped_entity_to_unit)
]
# Lock/re-create the resolved parents on THIS transaction before linking,
# closing the window where prune_orphan_entities could have deleted one
# between Phase-1 resolution and this insert (#2662).
await entity_resolver.reassert_entities_batch(bank_id, resolved_entities, conn=conn)
await entity_resolver.link_units_to_entities_batch(unit_entity_pairs, conn=conn)
log_buffer.append(f" Insert unit_entities: {len(unit_entity_pairs)} pairs in {time.time() - step_start:.3f}s")
@@ -480,6 +521,7 @@ async def _insert_facts_and_links(
bank_id,
unit_ids,
embeddings_for_links,
threshold=config.semantic_link_min_similarity,
pre_computed_ann_links=semantic_ann_links,
ops=ops,
)
@@ -549,9 +591,90 @@ async def _extract_and_embed(
embeddings = await embedding_processing.generate_embeddings_batch(embeddings_model, augmented_texts)
log_buffer.append(f" Generate embeddings: {len(embeddings)} embeddings in {time.time() - step_start:.3f}s")
processed_facts = [ProcessedFact.from_extracted_fact(ef, emb) for ef, emb in zip(extracted_facts, embeddings)]
fact_batch = _process_extracted_facts(extracted_facts, embeddings)
return extracted_facts, processed_facts, chunks, usage
return fact_batch.extracted_facts, fact_batch.processed_facts, chunks, usage
def _remap_causal_relations(
relations_per_fact: list[list[CausalRelation]],
retained_index_by_original: list[int | None],
) -> list[list[CausalRelation]]:
"""Remap a causal relation matrix after facts have been filtered.
Both the source row and each ``target_fact_index`` use fact ordinals. A
rejected source disappears with its row; a relation to a rejected target
must disappear rather than silently pointing at the next surviving fact.
"""
remapped = [[] for retained_index in retained_index_by_original if retained_index is not None]
for original_source, retained_source in enumerate(retained_index_by_original):
if retained_source is None:
continue
for relation in relations_per_fact[original_source]:
original_target = relation.target_fact_index
retained_target = (
retained_index_by_original[original_target]
if 0 <= original_target < len(retained_index_by_original)
else None
)
if retained_target is None:
continue
remapped[retained_source].append(
CausalRelation(
relation_type=relation.relation_type,
target_fact_index=retained_target,
)
)
return remapped
def _process_extracted_facts(
extracted_facts: list[ExtractedFact],
embeddings: list[list[float]],
) -> _ProcessedFactBatch:
"""Process facts while preserving their positional relationships.
``ProcessedFact.from_extracted_fact`` may reject a degenerate fact. Keep
the surviving extracted and processed facts in lockstep, and translate
causal ordinals from the original extraction into that retained sequence.
The returned index table is also used by transfer import for archive-only
links and observation source references.
"""
if len(extracted_facts) != len(embeddings):
raise ValueError(
f"Extracted facts/embeddings length mismatch: {len(extracted_facts)} facts, {len(embeddings)} embeddings"
)
retained_extracted: list[ExtractedFact] = []
processed_facts: list[ProcessedFact] = []
retained_index_by_original: list[int | None] = [None] * len(extracted_facts)
for original_index, (extracted_fact, embedding) in enumerate(zip(extracted_facts, embeddings, strict=True)):
processed_fact = ProcessedFact.from_extracted_fact(extracted_fact, embedding)
if processed_fact is None:
continue
retained_index_by_original[original_index] = len(processed_facts)
retained_extracted.append(extracted_fact)
processed_facts.append(processed_fact)
remapped_relations = _remap_causal_relations(
[fact.causal_relations for fact in extracted_facts],
retained_index_by_original,
)
for extracted_fact, processed_fact, causal_relations in zip(
retained_extracted,
processed_facts,
remapped_relations,
strict=True,
):
extracted_fact.causal_relations = causal_relations
processed_fact.causal_relations = causal_relations
return _ProcessedFactBatch(
extracted_facts=retained_extracted,
processed_facts=processed_facts,
retained_index_by_original=retained_index_by_original,
)
async def retain_batch(
@@ -732,7 +855,7 @@ async def retain_batch(
document_id=_item_doc_id,
decision=_decision,
)
_audit_memory_defense(
await _audit_memory_defense(
audit_logger,
bank_id=bank_id,
document_id=_item_doc_id,
@@ -831,6 +954,12 @@ async def retain_batch(
first = contents_dicts[0]
if first.get("context"):
existing_content["context"] = first["context"]
if first.get("event_date"):
existing_content["event_date"] = first["event_date"]
if first.get("metadata"):
existing_content["metadata"] = first["metadata"]
if first.get("observation_scopes") is not None:
existing_content["observation_scopes"] = first["observation_scopes"]
if first.get("tags"):
existing_content["tags"] = first["tags"]
contents_dicts = [existing_content, *contents_dicts]
@@ -852,6 +981,12 @@ async def retain_batch(
contents_dicts = [{"content": json.dumps(_merged, ensure_ascii=False)}]
if first.get("context"):
contents_dicts[0]["context"] = first["context"]
if first.get("event_date"):
contents_dicts[0]["event_date"] = first["event_date"]
if first.get("metadata"):
contents_dicts[0]["metadata"] = first["metadata"]
if first.get("observation_scopes") is not None:
contents_dicts[0]["observation_scopes"] = first["observation_scopes"]
if first.get("tags"):
contents_dicts[0]["tags"] = first["tags"]
except (json.JSONDecodeError, ValueError, TypeError):
@@ -989,6 +1124,8 @@ async def _run_final_semantic_ann(
pool: Any,
bank_id: str,
unit_ids: list[str],
*,
threshold: float,
log_buffer: list[str],
) -> None:
"""
@@ -1067,6 +1204,7 @@ async def _run_final_semantic_ann(
chunk_embs,
fact_types=chunk_ftypes,
top_k=20, # Recall uses at most 20 neighbors
threshold=threshold,
log_buffer=log_buffer,
)
if ann_links:
@@ -1204,6 +1342,14 @@ async def _streaming_retain_batch(
retain_params, merged_tags = _build_retain_params(contents_dicts, document_tags)
# Track whether document tracking has been done (by the first batch)
doc_tracking_done = [False]
# Track whether the transactional-outbox callback has already fired inside a
# batch write TXN. The in-TXN fire only runs on a final facts-bearing batch
# (is_last=True); two success paths never reach it — a committed-chunk count
# that lands exactly on a chunk_batch_size boundary (the sentinel drains an
# empty batch), and a final batch that extracts zero facts (it returns before
# the insert). A post-loop fallback fires the callback in those cases, so this
# flag exists to guarantee the callback fires exactly once.
outbox_fired = [False]
# ---------------------------------------------------------------------------
# Producer-consumer pipeline: LLM extraction runs concurrently with DB writes
@@ -1267,26 +1413,38 @@ async def _streaming_retain_batch(
tasks: list[asyncio.Task] = []
skipped_total = 0
for i, chunk_text in enumerate(all_pre_chunks):
chunk_hash = chunk_storage.compute_chunk_hash(chunk_text)
if chunk_hash in existing_chunk_hashes:
# Memory: skipped chunks aren't needed either.
all_pre_chunks[i] = ""
skipped_total += 1
continue
tasks.append(asyncio.create_task(_extract_one(i, chunk_text)))
try:
for i, chunk_text in enumerate(all_pre_chunks):
chunk_hash = chunk_storage.compute_chunk_hash(chunk_text)
if chunk_hash in existing_chunk_hashes:
# Memory: skipped chunks aren't needed either.
all_pre_chunks[i] = ""
skipped_total += 1
continue
tasks.append(asyncio.create_task(_extract_one(i, chunk_text)))
if skipped_total > 0:
log_buffer.append(f"[streaming] Producer: skipped {skipped_total}/{total_chunks} already-committed chunks")
if skipped_total > 0:
log_buffer.append(
f"[streaming] Producer: skipped {skipped_total}/{total_chunks} already-committed chunks"
)
# Wait for all extractions; collect exceptions
results = await asyncio.gather(*tasks, return_exceptions=True)
for r in results:
if isinstance(r, BaseException):
producer_error.append(r)
# Wait for all extractions; collect exceptions
results = await asyncio.gather(*tasks, return_exceptions=True)
for r in results:
if isinstance(r, BaseException):
producer_error.append(r)
# Signal the consumer that production is done
await chunk_queue.put(None)
# Signal the consumer that production is done
await chunk_queue.put(None)
finally:
# Cancellation arriving mid-fan-out (the consumer failed, or the worker's
# wall-clock ceiling fired) must not strand extraction tasks. Cancelling
# the gather above already propagates to them, but tasks created before
# we reach it would otherwise survive and park on `chunk_queue.put()`
# for the life of the process.
for extraction in tasks:
if not extraction.done():
extraction.cancel()
# ---- DB Consumer ----
# Drains enriched chunks from the queue in batches and runs
@@ -1376,12 +1534,26 @@ async def _streaming_retain_batch(
# from a single oversized item sharing one document_id — without it
# each sub-batch restarts at 0 and their chunk_ids collide (#1888).
doc_chunk_index = global_idx + chunk_index_offset
for fact in extracted:
fact_index_offset = len(batch_processed)
for fact, processed_fact in zip(extracted, processed, strict=True):
fact.content_index = content_idx_in_batch
if fact.chunk_index is not None:
fact.chunk_index = doc_chunk_index
for pf in processed:
pf.content_index = content_idx_in_batch
processed_fact.content_index = content_idx_in_batch
# Each producer call extracts one chunk, so its causal ordinals
# start at zero. Translate them into the combined consumer-batch
# sequence before link creation; otherwise later chunks can point
# at equally numbered facts from the first completed chunk.
causal_relations = [
CausalRelation(
relation_type=relation.relation_type,
target_fact_index=relation.target_fact_index + fact_index_offset,
)
for relation in processed_fact.causal_relations
]
fact.causal_relations = causal_relations
processed_fact.causal_relations = causal_relations
for cm in chunk_meta:
cm.chunk_index = doc_chunk_index
@@ -1394,7 +1566,12 @@ async def _streaming_retain_batch(
nonlocal total_usage
total_usage = total_usage + batch_usage
if not batch_extracted:
# ``batch_extracted`` contains only survivors after the degenerate-text
# guard. Chunk metadata still records whether extraction originally
# produced facts, so an all-rejected batch follows the normal write path
# and preserves chunk/outbox behavior from before filtering was added.
had_extracted_facts = bool(batch_extracted) or any(chunk.fact_count for chunk in batch_chunk_meta)
if not had_extracted_facts:
# Even with 0 facts, the first batch must still run document tracking
# (cascade-delete + insert doc row) to establish ownership and prevent
# concurrent requests from interleaving. Later batches can safely skip.
@@ -1422,6 +1599,7 @@ async def _streaming_retain_batch(
combined_content,
retain_params,
merged_tags,
store_document_text=getattr(config, "store_document_text", True),
)
else:
await fact_storage.handle_document_tracking(
@@ -1433,6 +1611,7 @@ async def _streaming_retain_batch(
retain_params,
merged_tags,
ops=pool.ops,
store_document_text=getattr(config, "store_document_text", True),
)
doc_tracking_done[0] = True
# Memory: combined_content has been persisted; release
@@ -1524,6 +1703,7 @@ async def _streaming_retain_batch(
combined_content,
retain_params,
merged_tags,
store_document_text=getattr(config, "store_document_text", True),
)
log_buffer.append(
f"[streaming] Document {effective_doc_id} updated "
@@ -1539,6 +1719,7 @@ async def _streaming_retain_batch(
retain_params,
merged_tags,
ops=pool.ops,
store_document_text=getattr(config, "store_document_text", True),
)
log_buffer.append(f"[streaming] Document {effective_doc_id} tracked (full content)")
doc_tracking_done[0] = True
@@ -1565,7 +1746,12 @@ async def _streaming_retain_batch(
chunk_id_map = {}
if batch_chunk_meta:
chunk_id_map = await chunk_storage.store_chunks_batch(
conn, bank_id, effective_doc_id, batch_chunk_meta, ops=pool.ops
conn,
bank_id,
effective_doc_id,
batch_chunk_meta,
ops=pool.ops,
store_document_text=getattr(config, "store_document_text", True),
)
log_buffer.append(
f" Store chunks: {len(batch_chunk_meta)} chunks in {time.time() - step_start:.3f}s"
@@ -1590,7 +1776,7 @@ async def _streaming_retain_batch(
batch_processed,
config,
log_buffer,
resolved_entity_ids=phase1.entities.resolved_entity_ids,
resolved_entities=phase1.entities.resolved_entities,
entity_to_unit=phase1.entities.entity_to_unit,
unit_to_entity_ids=phase1.entities.unit_to_entity_ids,
semantic_ann_links=[],
@@ -1601,13 +1787,27 @@ async def _streaming_retain_batch(
logger.info(f"[streaming] Phase 2 (write txn): {time.time() - p2_start:.3f}s")
# Best-effort: flush entity_cooccurrences and other deferred stats.
try:
await entity_resolver.flush_pending_stats()
except Exception:
logger.warning(
f"Entity stats flush (consumer batch {consumer_batch_idx + 1}) failed", exc_info=True
)
# The write TXN above committed the transactional-outbox row in the
# same transaction as this batch's facts. Record it so the post-loop
# fallback doesn't queue a duplicate delivery.
if is_last and outbox_callback is not None:
outbox_fired[0] = True
# Best-effort: flush entity_cooccurrences and other deferred stats.
#
# This MUST run after the `acquire_with_retry` block above has exited,
# not inside it: flush_pending_stats() acquires its own connection, and
# the write above is only committed when the enclosing acquire() block
# exits. On Oracle (oracledb does not autocommit — the backend commits
# on clean exit of acquire()) doing this inside the block deadlocks
# permanently: connection #2 waits on the row locks the still-open
# connection #1 holds on `entities`, while connection #1 cannot commit
# until this call returns. Oracle never reports ORA-00060 for it,
# because session #1 is blocked in Python rather than on the database.
try:
await entity_resolver.flush_pending_stats()
except Exception:
logger.warning(f"Entity stats flush (consumer batch {consumer_batch_idx + 1}) failed", exc_info=True)
logger.info(
f"[streaming] Consumer batch {consumer_batch_idx + 1} total "
@@ -1682,8 +1882,27 @@ async def _streaming_retain_batch(
logger.warning("Failed to check operation recovery state", exc_info=True)
if not facts_already_committed:
# Run producer and consumer concurrently
await asyncio.gather(_llm_producer(), _db_consumer())
# Run producer and consumer concurrently.
#
# Cancellation is explicit because plain gather() leaks: when the consumer
# raises (a deadlock victim, a lock timeout) gather propagates that error
# immediately but leaves the producer — and every extraction task under it
# — running. Those tasks then block forever on `chunk_queue.put()` into a
# queue nobody drains, pinning their chunk payloads and still spending LLM
# permits and tokens on an operation that already failed (#3002). The same
# applies when the worker's wall-clock ceiling cancels us from above.
producer_task = asyncio.create_task(_llm_producer())
consumer_task = asyncio.create_task(_db_consumer())
try:
await asyncio.gather(producer_task, consumer_task)
finally:
for pipeline_task in (producer_task, consumer_task):
if not pipeline_task.done():
pipeline_task.cancel()
# Await the cancellations so neither half outlives this call; the
# results are already accounted for by the gather above (or by the
# exception that is propagating).
await asyncio.gather(producer_task, consumer_task, return_exceptions=True)
# Propagate producer errors (e.g. LLM failures)
if producer_error:
@@ -1716,6 +1935,7 @@ async def _streaming_retain_batch(
combined_content,
retain_params,
merged_tags,
store_document_text=getattr(config, "store_document_text", True),
)
else:
await fact_storage.handle_document_tracking(
@@ -1727,6 +1947,7 @@ async def _streaming_retain_batch(
retain_params,
merged_tags,
ops=pool.ops,
store_document_text=getattr(config, "store_document_text", True),
)
doc_tracking_done[0] = True
# Memory: combined_content has been persisted and won't be
@@ -1734,6 +1955,21 @@ async def _streaming_retain_batch(
combined_content = ""
log_buffer.append(f"[streaming] Document {effective_doc_id} tracked (no facts extracted)")
# Transactional-outbox fallback. The in-TXN fire only runs on a final
# facts-bearing batch (is_last=True). When the committed-chunk count lands
# exactly on a chunk_batch_size boundary the sentinel drains an empty batch
# and never marks one last; when the final batch extracts zero facts it
# returns before the insert; and when every chunk is skipped as already
# committed no batch runs at all. In each of those the retain still
# succeeded, so the retain.completed delivery must be queued — exactly once,
# in its own transaction (there is no batch TXN left to attach it to). Skip
# it on a concurrent takeover: an aborted request must not emit completion.
if outbox_callback is not None and not outbox_fired[0] and not pipeline_aborted[0]:
async with acquire_with_retry(pool) as conn:
async with conn.transaction():
await outbox_callback(conn)
outbox_fired[0] = True
# Mark facts as committed in operation metadata (crash recovery checkpoint)
if operation_id and all_unit_ids:
try:
@@ -1787,7 +2023,13 @@ async def _streaming_retain_batch(
if all_unit_ids and not pipeline_aborted[0]:
ann_start = time.time()
try:
await _run_final_semantic_ann(pool, bank_id, all_unit_ids, log_buffer)
await _run_final_semantic_ann(
pool,
bank_id,
all_unit_ids,
threshold=config.semantic_link_min_similarity,
log_buffer=log_buffer,
)
except Exception:
# ANN pass is best-effort. FK violations can occur if a concurrent
# retain cascade-deleted our units between the batch commit and here.
@@ -1901,12 +2143,26 @@ async def _try_delta_retain(
# between this read and the write. The write TXN verifies the hash hasn't
# changed; if it has, we fall back to streaming (which has full protection).
async with acquire_with_retry(pool) as conn:
if document_body_override is not None:
doc_row_at_load = await conn.fetchrow(
f"SELECT content_hash, original_text FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2",
effective_doc_id,
bank_id,
)
doc_hash_at_load = doc_row_at_load["content_hash"] if doc_row_at_load else None
original_text_at_load = doc_row_at_load["original_text"] if doc_row_at_load else None
else:
doc_hash_at_load = await conn.fetchval(
f"SELECT content_hash FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2",
effective_doc_id,
bank_id,
)
original_text_at_load = None
# Load chunks after the document version. If a concurrent writer commits
# between these reads, the hash precondition on metadata-only writes (or
# the extraction freshness recheck below) forces a streaming fallback.
existing_chunks = await chunk_storage.load_existing_chunks(conn, bank_id, effective_doc_id)
doc_hash_at_load = await conn.fetchval(
f"SELECT content_hash FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2",
effective_doc_id,
bank_id,
)
if not existing_chunks:
return None
@@ -1938,6 +2194,30 @@ async def _try_delta_retain(
)
if not unchanged_indices:
if _is_strict_append_of_stored_document(
original_text_at_load,
document_body_override,
config,
):
log_buffer.append(
"[delta] First oversized slice has no stored chunk match, but "
"the complete document strictly appends the stored source — "
"preserving historical chunks and advancing document metadata"
)
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,
config=config,
expected_content_hash=doc_hash_at_load,
)
logger.info(f"Delta retain: no unchanged chunks for {effective_doc_id}, falling back to full retain")
return None
@@ -1958,6 +2238,7 @@ async def _try_delta_retain(
outbox_callback,
document_body_override=document_body_override,
config=config,
expected_content_hash=doc_hash_at_load,
)
# Build content items for only the changed/new chunks
@@ -1976,6 +2257,7 @@ async def _try_delta_retain(
outbox_callback,
document_body_override=document_body_override,
config=config,
expected_content_hash=doc_hash_at_load,
)
# Freshness recheck BEFORE the (expensive) LLM extraction.
@@ -2028,6 +2310,7 @@ async def _try_delta_retain(
outbox_callback,
document_body_override=document_body_override,
config=config,
expected_content_hash=recheck_hash,
)
log_buffer.append(
f"[delta] Recheck: {len(recheck.changed) + len(recheck.new) + len(recheck.removed)} chunks still differ — "
@@ -2157,7 +2440,12 @@ async def _try_delta_retain(
for cm in new_chunk_metadata
]
chunk_id_map = await chunk_storage.store_chunks_batch(
conn, bank_id, effective_doc_id, remapped_chunks, ops=pool.ops
conn,
bank_id,
effective_doc_id,
remapped_chunks,
ops=pool.ops,
store_document_text=getattr(config, "store_document_text", True),
)
for chunk_idx, chunk_id in chunk_id_map.items():
chunk_id_map_by_doc[(effective_doc_id, chunk_idx)] = chunk_id
@@ -2186,7 +2474,7 @@ async def _try_delta_retain(
processed_facts,
config,
log_buffer,
resolved_entity_ids=phase1.entities.resolved_entity_ids,
resolved_entities=phase1.entities.resolved_entities,
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,
@@ -2194,12 +2482,6 @@ async def _try_delta_retain(
ops=pool.ops,
)
# Flush deferred entity_cooccurrences stats (post-transaction, best-effort).
try:
await entity_resolver.flush_pending_stats()
except Exception:
logger.warning("Entity stats flush failed — retrieval unaffected", exc_info=True)
total_time = time.time() - start_time
log_buffer.append(f"{'=' * 60}")
log_buffer.append(
@@ -2210,6 +2492,14 @@ async def _try_delta_retain(
log_buffer.append(f"{'=' * 60}")
logger.info("\n" + "\n".join(log_buffer) + "\n")
# Flush deferred entity_cooccurrences stats (best-effort). Must run after
# the acquire() block above has exited — see the streaming path for why
# doing this while still holding the connection deadlocks on Oracle.
try:
await entity_resolver.flush_pending_stats()
except Exception:
logger.warning("Entity stats flush failed — retrieval unaffected", exc_info=True)
if db_semaphore is not None:
async with db_semaphore:
await _run_delta_db_work()
@@ -2236,16 +2526,22 @@ async def _delta_metadata_only(
*,
document_body_override: str | None = None,
config: Any = None,
):
expected_content_hash: str | None = None,
) -> tuple[list[list[str]], TokenUsage, int] | None:
"""Handle the case where no chunks changed — just update document metadata and tags."""
async with acquire_with_retry(pool) as conn:
async with conn.transaction():
# Lock the document row to serialize with concurrent retains
await conn.fetchval(
current_content_hash = await conn.fetchval(
f"SELECT content_hash FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2 FOR UPDATE",
document_id,
bank_id,
)
if expected_content_hash is not None and current_content_hash != expected_content_hash:
log_buffer.append(
f"[delta] Document {document_id} changed before metadata update — falling back to full retain"
)
return None
# When this sub-batch is a slice of an oversized item, write the
# full original body (issue #1838) instead of just the slice.
# Redact the override since it bypassed per-chunk screening.
@@ -5,11 +5,14 @@ These dataclasses provide type safety throughout the retain operation,
from content input to fact storage.
"""
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, TypedDict
from uuid import UUID
logger = logging.getLogger(__name__)
class RetainContentDict(TypedDict, total=False):
"""Type definition for content items in retain_batch_async.
@@ -96,10 +99,12 @@ class CausalRelation:
"""
Causal relationship between facts.
Represents how one fact was caused by another.
Retain emits only the backward-looking ``caused_by`` form. Transfer import
reuses this structure to restore historical causal types without allowing
normal retain writes to create them.
"""
relation_type: str # "caused_by"
relation_type: str # ``caused_by`` for retain; legacy types for transfer restore
target_fact_index: int # Index of the target fact in the batch
@@ -185,10 +190,47 @@ class ProcessedFact:
"""Check if this fact was marked as a duplicate."""
return self.unit_id is None
@staticmethod
def _is_degenerate_text(text: str) -> bool:
"""Check if fact text has zero information content.
Rejects empty strings, whitespace-only, single punctuation marks,
and common LLM hallucination patterns that carry no semantic meaning.
"""
stripped = (text or "").strip()
if not stripped:
return True
# Single or repeated punctuation patterns with no semantic content
degenerate_patterns = {
"...",
"",
"-",
"--",
"---",
".",
"..",
"",
"·",
"*",
"**",
"***",
"_,_",
"_, _, _",
}
if stripped in degenerate_patterns:
return True
# Strings composed entirely of punctuation and whitespace
if all(c in ".,;:!?-–—…\"'`´ \t\n\r" for c in stripped):
return True
# Very short text (<= 2 chars) that is only punctuation
if len(stripped) <= 2 and all(not c.isalnum() for c in stripped):
return True
return False
@staticmethod
def from_extracted_fact(
extracted_fact: "ExtractedFact", embedding: list[float], chunk_id: str | None = None
) -> "ProcessedFact":
) -> "ProcessedFact | None":
"""
Create ProcessedFact from ExtractedFact.
@@ -198,8 +240,17 @@ class ProcessedFact:
chunk_id: Optional chunk ID
Returns:
ProcessedFact ready for storage
ProcessedFact ready for storage, or None if the fact text is degenerate
(zero information content punctuation-only, empty, etc.)
"""
fact_text = extracted_fact.fact_text or ""
if ProcessedFact._is_degenerate_text(fact_text):
logger.warning(
f"Rejected degenerate fact text: type={extracted_fact.fact_type}, "
f"text={fact_text[:80]!r}, entities={extracted_fact.entities}"
)
return None
# Use occurred dates only if explicitly provided by LLM
occurred_start = extracted_fact.occurred_start
occurred_end = extracted_fact.occurred_end
@@ -209,7 +260,7 @@ class ProcessedFact:
entities = [EntityRef(name=name) for name in extracted_fact.entities]
return ProcessedFact(
fact_text=extracted_fact.fact_text,
fact_text=fact_text,
fact_type=extracted_fact.fact_type,
embedding=embedding,
occurred_start=occurred_start,
@@ -226,19 +277,43 @@ class ProcessedFact:
)
@dataclass
class ResolvedEntity:
"""Identity of a resolved entity carried across the retain phase boundary.
``canonical_name`` is the value stored on the entity row (NOT the raw input
mention), captured during Phase-1 resolution. It is threaded to Phase 2 so a
parent pruned between phases can be re-created with its real name the row
is gone by then, so the name is otherwise unrecoverable (#2662).
"""
entity_id: str
canonical_name: str
def __post_init__(self) -> None:
# Callers pass UUID objects or strings; normalize once so downstream
# comparisons, set membership, and SQL binds all see a plain str.
self.entity_id = str(self.entity_id)
@dataclass
class EntityResolutionResult:
"""
Result of Phase 1 entity resolution.
Contains resolved entity IDs and the mapping data needed to remap
Contains resolved entity identities and the mapping data needed to remap
placeholder unit IDs to real IDs after fact insertion in Phase 2.
"""
resolved_entity_ids: list[str]
resolved_entities: list[ResolvedEntity]
entity_to_unit: list[tuple]
unit_to_entity_ids: dict[str, list[str]]
@property
def resolved_entity_ids(self) -> list[str]:
"""Entity IDs in flattened resolution order (used by link remapping)."""
return [entity.entity_id for entity in self.resolved_entities]
@dataclass
class Phase1Result:
@@ -27,6 +27,23 @@ def fq_table(table_name: str) -> str:
return f"{get_current_schema()}.{table_name}"
def fq_routine(name: str) -> str:
"""Schema-qualified name of a cross-tenant discovery routine.
These routines are database-global each enumerates ``pg_class`` across every
schema and dispatches per schema so exactly one copy exists, installed into
the configured schema by ``b6d2f8a4c1e7``. Calling it through the configured
schema rather than a hardcoded ``public.`` is what makes a deployment living
in a dedicated non-``public`` schema work (#2638).
Unlike :func:`fq_table` this ignores the per-request schema contextvar: the
routines are deliberately cross-tenant, called from background loops that have
no request context.
"""
schema = get_config().database_schema or "public"
return '"' + schema.replace('"', '""') + '".' + name
def fq_table_explicit(table: str, schema: str | None = None) -> str:
"""Get fully-qualified table name with an explicit schema override.
@@ -40,14 +40,13 @@ class GraphRetriever(ABC):
fact_type: str,
budget: int,
query_text: str | None = None,
semantic_seeds: list[RetrievalResult] | None = None,
temporal_seeds: list[RetrievalResult] | None = None,
adjacency=None, # TypedAdjacency, optional pre-loaded graph
tags: list[str] | None = None, # Visibility scope tags for filtering
tags_match: TagsMatch = "any", # How to match tags: 'any' (OR) or 'all' (AND)
tag_groups: list[TagGroup] | None = None, # Compound boolean tag filter groups
created_after: datetime | None = None, # Only include memory_units created after this time
created_before: datetime | None = None, # Only include memory_units created before this time
preselected_semantic_seeds: list[RetrievalResult] | None = None,
) -> tuple[list[RetrievalResult], GraphRetrievalTimings | None]:
"""
Retrieve relevant facts via graph traversal.
@@ -59,10 +58,9 @@ class GraphRetriever(ABC):
fact_type: Fact type to filter ('world', 'experience', 'observation')
budget: Maximum number of nodes to explore/return
query_text: Original query text (optional, for some strategies)
semantic_seeds: Pre-computed semantic entry points (from semantic retrieval)
temporal_seeds: Pre-computed temporal entry points (from temporal retrieval)
adjacency: Pre-loaded typed adjacency graph (optional)
tags: Optional list of tags for visibility filtering (OR matching)
preselected_semantic_seeds: Independently thresholded graph entry points already fetched by the caller
Returns:
Tuple of (List of RetrievalResult with activation scores, optional timing info)
@@ -1,16 +1,16 @@
"""
Link Expansion graph retrieval.
Expands from semantic/temporal seeds through three parallel, first-class signals
stored in memory_links:
Selects bounded semantic seeds, then expands through three parallel,
first-class signals stored in memory_links:
1. Entity links query-time self-join through unit_entities. Score = number of distinct
shared entities between the seed set and each candidate, computed via
COUNT(DISTINCT entity_id). Uses a LATERAL per-entity cap
(graph_per_entity_limit, default 200) to prevent high-fanout entities
from exploding the self-join intermediate rows.
2. Semantic links precomputed kNN graph (each new fact linked to its top-5 most
similar existing facts at insert time, similarity >= 0.7). Checked
2. Semantic links precomputed kNN graph (each new fact linked to its most
similar existing facts at insert time, subject to the configured threshold). Checked
in both directions since the graph is not symmetric. Score = weight.
3. Causal links explicit causal chains (causes/caused_by/enables/prevents).
Score = weight + 1.0 (boosted as highest-quality signal).
@@ -31,7 +31,7 @@ import time
from datetime import datetime
from typing import Any
from ...config import get_config
from ...config import DEFAULT_GRAPH_SEED_MIN_SIMILARITY, get_config
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from .graph_retrieval import GraphRetriever
@@ -40,6 +40,8 @@ from .types import GraphRetrievalTimings, RetrievalResult
logger = logging.getLogger(__name__)
GRAPH_SEED_LIMIT = 20
async def _find_semantic_seeds(
conn,
@@ -47,7 +49,7 @@ async def _find_semantic_seeds(
bank_id: str,
fact_type: str,
limit: int = 20,
threshold: float = 0.3,
threshold: float = DEFAULT_GRAPH_SEED_MIN_SIMILARITY,
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
tag_groups: list[TagGroup] | None = None,
@@ -127,14 +129,13 @@ class LinkExpansionRetriever(GraphRetriever):
fact_type: str,
budget: int,
query_text: str | None = None,
semantic_seeds: list[RetrievalResult] | None = None,
temporal_seeds: list[RetrievalResult] | None = None,
adjacency=None,
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
tag_groups: list[TagGroup] | None = None,
created_after: "datetime | None" = None,
created_before: "datetime | None" = None,
preselected_semantic_seeds: list[RetrievalResult] | None = None,
) -> tuple[list[RetrievalResult], GraphRetrievalTimings | None]:
"""
Retrieve facts by expanding links from seeds.
@@ -146,10 +147,9 @@ class LinkExpansionRetriever(GraphRetriever):
fact_type: Fact type to filter
budget: Maximum results to return
query_text: Original query text (unused)
semantic_seeds: Pre-computed semantic entry points
temporal_seeds: Pre-computed temporal entry points
adjacency: Unused, kept for interface compatibility
tags: Optional list of tags for visibility filtering
preselected_semantic_seeds: Graph-specific entry points derived from a shared semantic candidate pool
Returns:
Tuple of (results, timings)
@@ -158,18 +158,18 @@ class LinkExpansionRetriever(GraphRetriever):
timings = GraphRetrievalTimings(fact_type=fact_type)
async with acquire_with_retry(pool) as conn:
# Find seeds if not provided
if semantic_seeds:
all_seeds = list(semantic_seeds)
else:
if preselected_semantic_seeds is None:
# A shared semantic pool is reusable only when its SQL threshold
# covers the independently configured graph threshold. Otherwise
# retain graph retrieval's own query and result semantics.
seeds_start = time.time()
all_seeds = await _find_semantic_seeds(
conn,
query_embedding_str,
bank_id,
fact_type,
limit=20,
threshold=0.3,
limit=GRAPH_SEED_LIMIT,
threshold=get_config().graph_seed_min_similarity,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
@@ -177,13 +177,16 @@ class LinkExpansionRetriever(GraphRetriever):
created_before=created_before,
)
timings.seeds_time = time.time() - seeds_start
logger.debug(
f"[LinkExpansion] Found {len(all_seeds)} semantic seeds for fact_type={fact_type} "
f"(tags={tags}, tags_match={tags_match})"
)
else:
all_seeds = preselected_semantic_seeds
if temporal_seeds:
all_seeds.extend(temporal_seeds)
logger.debug(
"LinkExpansion found %s semantic seeds for fact_type=%s (tags=%s, tags_match=%s)",
len(all_seeds),
fact_type,
tags,
tags_match,
)
if not all_seeds:
return [], timings
@@ -211,7 +214,7 @@ class LinkExpansionRetriever(GraphRetriever):
#
# Entity score: tanh(count × 0.5) maps shared-entity count to [0, 1]:
# 1 entity → 0.46, 2 → 0.76, 3 → 0.91, 4 → 0.96 (saturates naturally)
# Semantic score: similarity weight, already ∈ [0.7, 1.0].
# Semantic score: similarity weight, already above the configured construction floor.
# Causal score: link weight, already ∈ [0, 1].
#
# Facts appearing in multiple signals accumulate higher scores, rewarding
@@ -243,12 +246,16 @@ class LinkExpansionRetriever(GraphRetriever):
}
sorted_ids = sorted(score_map.keys(), key=lambda x: score_map[x], reverse=True)[:budget]
rows = [row_map[fact_id] for fact_id in sorted_ids]
results = []
for row in rows:
for fact_id in sorted_ids:
row = row_map[fact_id]
result = RetrievalResult.from_db_row(dict(row))
result.activation = row["score"]
# ``activation`` is used to re-sort graph results after fact types are
# combined. It must retain the final additive score rather than the
# raw score from one signal, which would otherwise discard the other
# signals and make the cross-fact-type order disagree with this order.
result.activation = score_map[fact_id]
results.append(result)
# filter_results_by_tags is a no-op when no filter applies (tags falsy and not
@@ -15,12 +15,12 @@ from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Optional
from ...config import get_config
from ...config import DEFAULT_BM25_MAX_QUERY_TERMS, DEFAULT_TEMPORAL_SEMANTIC_MIN_SIMILARITY, get_config
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from ..sql import create_sql_dialect
from .graph_retrieval import GraphRetriever
from .link_expansion_retrieval import LinkExpansionRetriever
from .link_expansion_retrieval import GRAPH_SEED_LIMIT, LinkExpansionRetriever
from .tags import TagGroup, TagsMatch, build_tag_groups_where_clause, build_tags_where_clause_simple
from .types import GraphRetrievalTimings, RetrievalResult
@@ -67,6 +67,15 @@ class MultiFactTypeRetrievalResult:
max_conn_wait: float = 0.0
@dataclass
class SemanticBm25Result:
"""Per-fact-type candidates returned by the shared semantic/BM25 query."""
semantic: list[RetrievalResult]
bm25: list[RetrievalResult]
graph_seeds: list[RetrievalResult] | None
# Default graph retriever instance (can be overridden)
_default_graph_retriever: GraphRetriever | None = None
@@ -106,7 +115,8 @@ async def retrieve_semantic_bm25_combined(
created_before: datetime | None = None,
min_semantic: float | None = None,
min_keyword: float | None = None,
) -> dict[str, tuple[list[RetrievalResult], list[RetrievalResult]]]:
graph_seed_min_similarity: float | None = None,
) -> dict[str, SemanticBm25Result]:
"""
Combined semantic + BM25 retrieval for multiple fact types in a single query.
@@ -138,9 +148,10 @@ async def retrieve_semantic_bm25_combined(
tags_match: Tag matching mode
Returns:
Dict mapping fact_type -> (semantic_results, bm25_results)
Candidate groups for each fact type. ``graph_seeds`` is ``None`` when
the semantic query's threshold is too strict to cover graph entry points.
"""
result_dict: dict[str, tuple[list[RetrievalResult], list[RetrievalResult]]] = {ft: ([], []) for ft in fact_types}
result_dict = {ft: SemanticBm25Result(semantic=[], bm25=[], graph_seeds=None) for ft in fact_types}
config = get_config()
tokens = tokenize_query(query_text)
@@ -222,7 +233,12 @@ async def retrieve_semantic_bm25_combined(
# --- BM25 UNION ALL arms (one per fact_type, only when tokens present) ---
if _include_bm25:
text_ext = config.text_search_extension
bm25_text_param: str = dialect.prepare_bm25_text(tokens, query_text, text_search_extension=text_ext)
bm25_text_param: str = dialect.prepare_bm25_text(
tokens,
query_text,
text_search_extension=text_ext,
max_query_terms=getattr(config, "bm25_max_query_terms", DEFAULT_BM25_MAX_QUERY_TERMS),
)
for i, ft in enumerate(fact_types):
arms.append(
dialect.build_bm25_arm(
@@ -302,8 +318,18 @@ async def retrieve_semantic_bm25_combined(
else:
raise
# Group results; trim semantic to limit (over-fetched for HNSW approximation).
sem_counts: dict[str, int] = {ft: 0 for ft in fact_types}
# Group results. The semantic SQL deliberately over-fetches for HNSW recall;
# when that pool also covers the graph threshold, derive graph entry points
# from the same ordered rows instead of issuing one duplicate ANN query per
# fact type. Convert only the prefix either consumer can observe, not the
# entire HNSW over-fetch pool.
graph_seed_threshold = (
graph_seed_min_similarity
if graph_seed_min_similarity is not None and sem_min <= graph_seed_min_similarity
else None
)
semantic_candidate_limit = max(limit, GRAPH_SEED_LIMIT if graph_seed_threshold is not None else 0)
semantic_candidates: dict[str, list[RetrievalResult]] = {ft: [] for ft in fact_types}
for r in rows:
row = dict(r)
source = row.pop("source")
@@ -311,11 +337,19 @@ async def retrieve_semantic_bm25_combined(
if ft not in result_dict:
continue
if source == "semantic":
if sem_counts[ft] < limit:
result_dict[ft][0].append(RetrievalResult.from_db_row(row))
sem_counts[ft] += 1
if len(semantic_candidates[ft]) < semantic_candidate_limit:
semantic_candidates[ft].append(RetrievalResult.from_db_row(row))
else:
result_dict[ft][1].append(RetrievalResult.from_db_row(row))
result_dict[ft].bm25.append(RetrievalResult.from_db_row(row))
for ft, candidates in semantic_candidates.items():
result_dict[ft].semantic.extend(candidates[:limit])
if graph_seed_threshold is not None:
result_dict[ft].graph_seeds = [
candidate
for candidate in candidates
if candidate.similarity is not None and candidate.similarity >= graph_seed_threshold
][:GRAPH_SEED_LIMIT]
return result_dict
@@ -388,7 +422,7 @@ async def retrieve_temporal_combined(
start_date: datetime,
end_date: datetime,
budget: int,
semantic_threshold: float = 0.1,
semantic_threshold: float = DEFAULT_TEMPORAL_SEMANTIC_MIN_SIMILARITY,
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
tag_groups: list[TagGroup] | None = None,
@@ -616,7 +650,7 @@ async def retrieve_temporal_combined(
# bank_id on memory_units lets the planner use idx_memory_units_bank_fact_type.
neighbors = await conn.fetch(
f"""
SELECT src.from_unit_id, mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.metadata,
SELECT src.from_unit_id, mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.metadata, mu.proof_count,
l.weight, l.link_type,
1 - (mu.embedding <=> $1::vector) AS similarity
FROM unnest($2::uuid[]) AS src(from_unit_id)
@@ -742,6 +776,7 @@ async def retrieve_all_fact_types_parallel(
import time
retriever = graph_retriever or get_default_graph_retriever()
config = get_config()
start_time = time.time()
timings: dict[str, float] = {}
@@ -778,6 +813,7 @@ async def retrieve_all_fact_types_parallel(
created_before=created_before,
min_semantic=min_semantic,
min_keyword=min_keyword,
graph_seed_min_similarity=config.graph_seed_min_similarity,
)
semantic_bm25_time = time.time() - semantic_bm25_start
@@ -793,7 +829,7 @@ async def retrieve_all_fact_types_parallel(
tc_start,
tc_end,
budget=thinking_budget,
semantic_threshold=min_semantic if min_semantic is not None else 0.1,
semantic_threshold=config.temporal_semantic_min_similarity,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
@@ -817,13 +853,12 @@ async def retrieve_all_fact_types_parallel(
fact_type=ft,
budget=thinking_budget,
query_text=query_text,
semantic_seeds=None,
temporal_seeds=None,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
created_after=created_after,
created_before=created_before,
preselected_semantic_seeds=semantic_bm25_results[ft].graph_seeds,
)
return ft, results, time.time() - graph_start, graph_timing
@@ -838,7 +873,8 @@ async def retrieve_all_fact_types_parallel(
for ft in fact_types:
# Get semantic + bm25 results for this fact type
semantic_results, bm25_results = semantic_bm25_results.get(ft, ([], []))
semantic_results = semantic_bm25_results[ft].semantic
bm25_results = semantic_bm25_results[ft].bm25
# Find graph results for this fact type
graph_results = []
@@ -22,7 +22,7 @@ class GraphRetrievalTimings:
pattern_count: int = 0 # Number of patterns executed
fusion: float = 0.0 # Time for RRF fusion
fetch: float = 0.0 # Time to fetch memory unit details
seeds_time: float = 0.0 # Time to find semantic seeds (if fallback used)
seeds_time: float = 0.0 # Time spent selecting semantic graph seeds
result_count: int = 0 # Number of results returned
# Detailed per-hop timing: list of {hop, exec_time, uncached, load_time, edges_loaded, total_time}
hop_details: list[dict] = field(default_factory=list)
@@ -300,19 +300,6 @@ class SQLDialect(ABC):
"""FOR UPDATE SKIP LOCKED clause (same on both PG and Oracle)."""
...
@abstractmethod
def advisory_lock(self, id_param: str) -> str:
"""Advisory lock expression.
Args:
id_param: Parameter placeholder for the lock ID.
Returns:
PG: "pg_try_advisory_lock($1)"
Oracle: "SELECT ... FOR UPDATE NOWAIT" equivalent.
"""
...
# -- UUID generation -------------------------------------------------
@abstractmethod
@@ -449,6 +436,7 @@ class SQLDialect(ABC):
query_text: str,
*,
text_search_extension: str = "native",
max_query_terms: int | None = None,
) -> str:
"""Prepare the text parameter value for BM25 search.
@@ -459,6 +447,8 @@ class SQLDialect(ABC):
tokens: Tokenized query words.
query_text: Original query text.
text_search_extension: Full-text search backend variant.
max_query_terms: Optional backend-specific token cap. 0 or None
leaves query terms uncapped.
Returns:
Prepared text string to bind as the BM25 text parameter.
@@ -203,10 +203,6 @@ class OracleDialect(SQLDialect):
def for_update_skip_locked(self) -> str:
return "FOR UPDATE SKIP LOCKED"
def advisory_lock(self, id_param: str) -> str:
# Oracle doesn't have advisory locks. Use SELECT FOR UPDATE NOWAIT on a lock row.
return "SELECT 1 FROM dual FOR UPDATE NOWAIT"
# -- UUID generation -------------------------------------------------
def generate_uuid(self) -> str:
@@ -303,6 +299,7 @@ class OracleDialect(SQLDialect):
query_text: str,
*,
text_search_extension: str = "native",
max_query_terms: int | None = None,
) -> str:
# Oracle Text: filter tokens with special chars, escape reserved words
# with curly braces (e.g. "about" → "{about}"), and join with OR.
@@ -118,9 +118,6 @@ class PostgreSQLDialect(SQLDialect):
def for_update_skip_locked(self) -> str:
return "FOR UPDATE SKIP LOCKED"
def advisory_lock(self, id_param: str) -> str:
return f"pg_try_advisory_lock({id_param})"
# -- UUID generation -------------------------------------------------
def generate_uuid(self) -> str:
@@ -254,8 +251,11 @@ class PostgreSQLDialect(SQLDialect):
query_text: str,
*,
text_search_extension: str = "native",
max_query_terms: int | None = None,
) -> str:
if text_search_extension in ("vchord", "pg_textsearch", "pgroonga", "pg_search"):
return query_text
if max_query_terms is not None and max_query_terms > 0:
tokens = tokens[:max_query_terms]
# native tsvector: join tokens with OR operator
return " | ".join(tokens)
@@ -0,0 +1,30 @@
"""Canonical JSON Schema serialization for OpenAI strict output."""
from typing import Any
from pydantic import BaseModel
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue
from pydantic_core import core_schema
class OpenAIStrictSchemaGenerator(GenerateJsonSchema):
"""Emit the strict JSON Schema subset required by OpenAI-compatible APIs."""
def model_schema(self, schema: core_schema.ModelSchema) -> JsonSchemaValue:
json_schema = super().model_schema(schema)
properties = json_schema.get("properties")
if type(properties) is dict:
json_schema["required"] = list(properties)
json_schema["additionalProperties"] = False
return json_schema
def default_schema(self, schema: core_schema.WithDefaultSchema) -> JsonSchemaValue:
json_schema = super().default_schema(schema)
if json_schema.get("default", object()) is None:
json_schema.pop("default")
return json_schema
def strict_json_schema(response_format: type[BaseModel]) -> dict[str, Any]:
"""Serialize a typed response model directly into OpenAI's strict subset."""
return response_format.model_json_schema(schema_generator=OpenAIStrictSchemaGenerator)
@@ -51,11 +51,15 @@ def _month_end(year: int, month: int) -> datetime:
def _extract_non_chinese_period(query: str, reference_date: datetime) -> DateRange | None:
if re.search(r"\b(yesterday|ayer|ieri|hier|gestern)\b", query, re.IGNORECASE):
if re.search(r"\b(yesterday|ayer|ieri|hier|gestern|вчера)\b", query, re.IGNORECASE):
d = reference_date - timedelta(days=1)
return _constraint(d, d)
if re.search(r"\b(today|hoy|oggi|aujourd\'?hui|heute)\b", query, re.IGNORECASE):
if re.search(r"\b(позавчера)\b", query, re.IGNORECASE):
d = reference_date - timedelta(days=2)
return _constraint(d, d)
if re.search(r"\b(today|hoy|oggi|aujourd\'?hui|heute|сегодня)\b", query, re.IGNORECASE):
return _constraint(reference_date, reference_date)
if re.search(r"\b(a\s+)?couple\s+(of\s+)?days?\s+ago\b", query, re.IGNORECASE):
@@ -64,20 +68,39 @@ def _extract_non_chinese_period(query: str, reference_date: datetime) -> DateRan
if re.search(r"\b(a\s+)?few\s+days?\s+ago\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(days=5), reference_date - timedelta(days=2))
if re.search(r"\b(пару|пар[ыу]?)\s+дн(?:ей|я)\s+назад\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(days=3), reference_date - timedelta(days=1))
if re.search(r"\bнесколько\s+дней\s+назад\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(days=5), reference_date - timedelta(days=2))
if re.search(r"\b(a\s+)?couple\s+(of\s+)?weeks?\s+ago\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(weeks=3), reference_date - timedelta(weeks=1))
if re.search(r"\b(a\s+)?few\s+weeks?\s+ago\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(weeks=5), reference_date - timedelta(weeks=2))
if re.search(r"\b(пару|пар[ыу]?)\s+недель\s+назад\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(weeks=3), reference_date - timedelta(weeks=1))
if re.search(r"\bнесколько\s+недель\s+назад\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(weeks=5), reference_date - timedelta(weeks=2))
if re.search(r"\b(a\s+)?couple\s+(of\s+)?months?\s+ago\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(days=90), reference_date - timedelta(days=30))
if re.search(r"\b(a\s+)?few\s+months?\s+ago\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(days=150), reference_date - timedelta(days=60))
if re.search(r"\b(пару|пар[ыу]?)\s+месяцев\s+назад\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(days=90), reference_date - timedelta(days=30))
if re.search(r"\bнесколько\s+месяцев\s+назад\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(days=150), reference_date - timedelta(days=60))
if re.search(
r"\b(last\s+week|la\s+semana\s+pasada|la\s+settimana\s+scorsa|la\s+semaine\s+derni[eè]re|letzte\s+woche)\b",
r"\b(last\s+week|la\s+semana\s+pasada|la\s+settimana\s+scorsa|la\s+semaine\s+derni[eè]re|letzte\s+woche"
r"|(?:на\s+)?прошлой\s+неделе|прошлая\s+неделя)\b",
query,
re.IGNORECASE,
):
@@ -85,7 +108,8 @@ def _extract_non_chinese_period(query: str, reference_date: datetime) -> DateRan
return _constraint(start, start + timedelta(days=6))
if re.search(
r"\b(last\s+month|el\s+mes\s+pasado|il\s+mese\s+scorso|le\s+mois\s+dernier|letzten?\s+monat)\b",
r"\b(last\s+month|el\s+mes\s+pasado|il\s+mese\s+scorso|le\s+mois\s+dernier|letzten?\s+monat"
r"|(?:в\s+)?прошлом\s+месяце|прошлый\s+месяц)\b",
query,
re.IGNORECASE,
):
@@ -95,7 +119,8 @@ def _extract_non_chinese_period(query: str, reference_date: datetime) -> DateRan
return _constraint(start, end)
if re.search(
r"\b(last\s+year|el\s+a[ñn]o\s+pasado|l\'anno\s+scorso|l\'ann[ée]e\s+derni[eè]re|letztes?\s+jahr)\b",
r"\b(last\s+year|el\s+a[ñn]o\s+pasado|l\'anno\s+scorso|l\'ann[ée]e\s+derni[eè]re|letztes?\s+jahr"
r"|(?:в\s+)?прошлом\s+году|прошлый\s+год)\b",
query,
re.IGNORECASE,
):
@@ -103,7 +128,8 @@ def _extract_non_chinese_period(query: str, reference_date: datetime) -> DateRan
return _constraint(datetime(year, 1, 1), datetime(year, 12, 31))
if re.search(
r"\b(last\s+weekend|el\s+fin\s+de\s+semana\s+pasado|lo\s+scorso\s+fine\s+settimana|le\s+week-?end\s+dernier|letztes?\s+wochenende)\b",
r"\b(last\s+weekend|el\s+fin\s+de\s+semana\s+pasado|lo\s+scorso\s+fine\s+settimana|le\s+week-?end\s+dernier"
r"|letztes?\s+wochenende|(?:на\s+|в\s+)?прошлых?\s+выходных|прошлые\s+выходные)\b",
query,
re.IGNORECASE,
):
@@ -114,20 +140,25 @@ def _extract_non_chinese_period(query: str, reference_date: datetime) -> DateRan
return _constraint(sat, sat + timedelta(days=1))
month_patterns = {
"january|enero|gennaio|janvier|januar": 1,
"february|febrero|febbraio|f[ée]vrier|februar": 2,
"march|marzo|mars|m[äa]rz": 3,
"april|abril|aprile|avril": 4,
"may|mayo|maggio|mai": 5,
"june|junio|giugno|juin|juni": 6,
"july|julio|luglio|juillet|juli": 7,
"august|agosto|ao[uû]t": 8,
"september|septiembre|settembre|septembre": 9,
"october|octubre|ottobre|octobre|oktober": 10,
"november|noviembre|novembre": 11,
"december|diciembre|dicembre|d[ée]cembre|dezember": 12,
"january|enero|gennaio|janvier|januar|январ[ьяе]": 1,
"february|febrero|febbraio|f[ée]vrier|februar|феврал[ьяе]": 2,
"march|marzo|mars|m[äa]rz|март[ае]?": 3,
"april|abril|aprile|avril|апрел[ьяе]": 4,
"may|mayo|maggio|mai|ма[йяе]": 5,
"june|junio|giugno|juin|juni|июн[ьяе]": 6,
"july|julio|luglio|juillet|juli|июл[ьяе]": 7,
"august|agosto|ao[uû]t|август[ае]?": 8,
"september|septiembre|settembre|septembre|сентябр[ьяе]": 9,
"october|octubre|ottobre|octobre|oktober|октябр[ьяе]": 10,
"november|noviembre|novembre|ноябр[ьяе]": 11,
"december|diciembre|dicembre|d[ée]cembre|dezember|декабр[ьяе]": 12,
}
for pattern, month_num in month_patterns.items():
# Skip when a day number precedes the month ("13 июля 2026", "13 July 2026"):
# that is an exact date, and collapsing it to the whole month loses precision.
# dateparser resolves those correctly, so let them fall through to it.
if re.search(rf"\b\d{{1,2}}\s+({pattern})\b", query, re.IGNORECASE):
continue
match = re.search(rf"\b({pattern})\s+(\d{{4}})\b", query, re.IGNORECASE)
if match:
year = int(match.group(2))
@@ -19,10 +19,14 @@ from decimal import Decimal
from typing import Any
from uuid import UUID
from ..causal_links import CAUSAL_LINK_TYPES
from ..db_utils import acquire_with_retry
from ..schema import fq_table
from .schema import (
CARRIED_HISTORY_TABLES,
HISTORY_TABLES,
SCHEMA_VERSION,
BankRowsJSONEncoding,
TransferCausalRelation,
TransferChunk,
TransferDocument,
@@ -71,9 +75,7 @@ _BANK_ROW_TABLES = ("banks", "mental_models", "directives", "webhooks")
# 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(
{
@@ -125,10 +127,9 @@ class _LoadedExport:
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")
# Retain currently writes only ``caused_by``. The legacy types stay in archives
# so importing a historical bank preserves its graph; temporal/semantic/entity
# links are regenerated against the target bank.
# Facts of these types are exported; observations are derived and excluded.
_EXPORTED_FACT_TYPES = ("world", "experience")
@@ -138,7 +139,12 @@ def _as_jsonb(value: Any) -> Any:
if value is None:
return None
if isinstance(value, str):
return json.loads(value)
try:
return json.loads(value)
except json.JSONDecodeError:
# Admin connections register a JSONB decoder, so a valid scalar such
# as `"combined"` arrives here as the already-decoded `combined`.
return value
return value
@@ -187,7 +193,12 @@ async def export_documents(
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)
# Carry per-fact consolidation lifecycle exactly when observations are
# carried: with observations in the archive the target must NOT re-derive
# them, so imported facts keep their consolidated/failed state. Without
# observations (the default document export) the target re-consolidates
# from scratch, so lifecycle is deliberately dropped.
loaded = await _load_documents(conn, bank_id, document_ids, include_lifecycle=include_observations)
documents = loaded.documents
observations = await _load_observations(conn, bank_id, loaded.unit_index) if include_observations else []
@@ -266,7 +277,13 @@ async def _dump_history_rows(conn: Any, table: str, bank_id: str) -> list[dict]:
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:
async def export_bank(
conn: Any,
bank_id: str,
*,
include_history: bool = False,
bank_rows_json_encoding: BankRowsJSONEncoding = "serialized",
) -> bytes:
"""Export an entire bank into a portable ZIP archive (no embeddings).
Produces a superset of the documents archive: the logical
@@ -281,17 +298,19 @@ async def export_bank(conn: Any, bank_id: str, *, include_history: bool = False)
``_current_schema`` and passes its raw connection; the engine acquires one
after tenant auth).
"""
loaded = await _load_documents(conn, bank_id, None)
# Whole-bank export always carries observations (they're bank-level state)
# and, with them, the per-fact consolidation lifecycle so the target restores
# exact eligibility instead of re-consolidating historical facts (#2965).
loaded = await _load_documents(conn, bank_id, None, include_lifecycle=True)
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:
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}
history_rows = {table: await _dump_bank_rows(conn, table, bank_id) for table in HISTORY_TABLES}
archive = io.BytesIO()
fact_total = 0
@@ -321,6 +340,7 @@ async def export_bank(conn: Any, bank_id: str, *, include_history: bool = False)
directive_count=len(bank_rows.get("directives", [])),
webhook_count=len(bank_rows.get("webhooks", [])),
includes_history=include_history,
bank_rows_json_encoding=bank_rows_json_encoding,
)
zf.writestr("manifest.json", manifest.model_dump_json(indent=2))
@@ -343,6 +363,7 @@ async def _load_documents(
conn: Any,
bank_id: str,
document_ids: list[str] | None,
include_lifecycle: bool = False,
) -> _LoadedExport:
"""Load and assemble TransferDocument payloads for the requested documents."""
doc_filter = "AND id = ANY($2)" if document_ids else ""
@@ -364,7 +385,7 @@ async def _load_documents(
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)
loaded = await _load_facts(conn, bank_id, selected_ids, include_lifecycle=include_lifecycle)
await _attach_entities(conn, loaded)
await _attach_causal_relations(conn, loaded)
@@ -459,17 +480,22 @@ async def _load_chunks(conn: Any, bank_id: str, doc_ids: list[str]) -> dict[str,
return chunks_by_doc
async def _load_facts(conn: Any, bank_id: str, doc_ids: list[str]) -> _LoadedFacts:
async def _load_facts(conn: Any, bank_id: str, doc_ids: list[str], include_lifecycle: bool = False) -> _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.
``include_lifecycle`` carries each fact's ``created_at`` / ``consolidated_at`` /
``consolidation_failed_at`` (whole-bank / with-observations export). It is left
off for the plain document export so the target re-consolidates from scratch.
"""
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
chunk_id, tags, observation_scopes,
created_at, consolidated_at, consolidation_failed_at
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND document_id = ANY($2)
@@ -498,6 +524,9 @@ async def _load_facts(conn: Any, bank_id: str, doc_ids: list[str]) -> _LoadedFac
tags=list(row["tags"] or []),
observation_scopes=_as_jsonb(row["observation_scopes"]),
chunk_index=_chunk_index_from_chunk_id(row["chunk_id"]),
created_at=row["created_at"] if include_lifecycle else None,
consolidated_at=row["consolidated_at"] if include_lifecycle else None,
consolidation_failed_at=row["consolidation_failed_at"] if include_lifecycle else None,
)
bucket.append(fact)
loaded.unit_index[row["id"]] = _UnitLocation(document_id=doc_id, ordinal=ordinal)
@@ -543,7 +572,7 @@ async def _attach_causal_relations(conn: Any, loaded: _LoadedFacts) -> None:
AND from_unit_id = ANY($2)
AND to_unit_id = ANY($2)
""",
list(_CAUSAL_LINK_TYPES),
list(CAUSAL_LINK_TYPES),
list(loaded.unit_index.keys()),
)
for row in rows:
@@ -18,8 +18,9 @@ from dataclasses import dataclass, field
from datetime import UTC, date, datetime
from typing import Any, Literal
from ..causal_links import CANONICAL_CAUSAL_LINK_TYPE, LEGACY_CAUSAL_LINK_TYPES
from ..db_utils import acquire_with_retry
from ..retain import bank_utils, chunk_storage, embedding_processing, fact_storage, orchestrator
from ..retain import bank_utils, chunk_storage, embedding_processing, fact_storage, link_utils, orchestrator
from ..retain.types import (
CausalRelation,
ChunkMetadata,
@@ -29,7 +30,10 @@ from ..retain.types import (
)
from ..schema import fq_table
from .schema import (
CARRIED_HISTORY_TABLES,
HISTORY_TABLES,
SCHEMA_VERSION,
BankRowsJSONEncoding,
TransferDocument,
TransferFact,
TransferManifest,
@@ -83,6 +87,14 @@ class _ObservationOutcome:
skipped: int = 0
@dataclass
class _ImportedFactBatch:
"""Inserted fact IDs paired with their ordinals in the source archive."""
unit_ids: list[str]
original_ordinals: list[int]
@dataclass
class ParsedArchive:
"""A transfer archive after parsing/validation."""
@@ -165,7 +177,7 @@ async def import_documents(
if target_id != document.id:
result.remapped_document_ids[document.id] = target_id
unit_ids = await _import_one_document(
imported_facts = await _import_one_document(
backend=backend,
embeddings_model=embeddings_model,
entity_resolver=entity_resolver,
@@ -178,16 +190,16 @@ async def import_documents(
outbox_callback_factory=outbox_callback_factory,
)
result.documents_imported += 1
result.facts_imported += len(unit_ids)
result.facts_imported += len(imported_facts.unit_ids)
result.imported_documents.append(
ImportedDocument(
document_id=target_id,
unit_ids=unit_ids,
unit_ids=imported_facts.unit_ids,
content=document.original_text or "",
tags=list(document.tags),
)
)
for ordinal, unit_id in enumerate(unit_ids):
for ordinal, unit_id in zip(imported_facts.original_ordinals, imported_facts.unit_ids, strict=True):
ref_map[(document.id, ordinal)] = unit_id
if parsed.observations:
@@ -221,8 +233,6 @@ _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
@@ -263,18 +273,29 @@ def parse_bank_archive(archive_bytes: bytes) -> ParsedBankArchive:
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):
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:
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:
def _resolve_bank_rows_json_encoding(manifest: TransferManifest) -> BankRowsJSONEncoding:
"""Resolve row JSON provenance, including the released v1 archive contract."""
return manifest.bank_rows_json_encoding or "decoded"
async def _restore_rows(
conn: Any,
table: str,
rows: list[dict],
*,
bank_rows_json_encoding: BankRowsJSONEncoding = "decoded",
) -> 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."""
@@ -301,9 +322,12 @@ async def _restore_rows(conn: Any, table: str, rows: list[dict]) -> int:
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))
# text and cast. Provenance is required because a decoded JSON
# scalar containing JSON text is indistinguishable from a raw
# serialized object after the outer archive JSON is parsed.
if value is not None and (bank_rows_json_encoding == "decoded" or not isinstance(value, str)):
value = json.dumps(value)
values.append(value)
placeholders.append(f"${position}::jsonb")
continue
if value is not None and isinstance(value, str):
@@ -352,6 +376,7 @@ async def import_bank(
if ops is None:
ops = backend.ops
parsed = parse_bank_archive(archive_bytes)
bank_rows_json_encoding = _resolve_bank_rows_json_encoding(parsed.manifest)
source_bank_id = parsed.manifest.source_bank_id
bank_id = target_bank_id or source_bank_id
@@ -374,10 +399,22 @@ async def import_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)
await _restore_rows(
conn,
"banks",
parsed.bank_rows.get("banks", []),
bank_rows_json_encoding=bank_rows_json_encoding,
)
# The restored banks row bypasses the fresh-INSERT gate that normally
# creates per-bank vector indexes, so create them explicitly here while
# the bank is still empty (facts are imported below, so the build is
# instant). get_or_create_bank_profile would NOT do this: the row now
# exists, so it takes the SELECT branch and skips index creation —
# leaving the restored bank falling back to the global index +
# post-filter (slower, under-returning recall). See #2645.
internal_id = await conn.fetchval(f"SELECT internal_id FROM {fq_table('banks')} WHERE bank_id = $1", bank_id)
if internal_id is not None:
await bank_utils.create_bank_vector_indexes(conn, bank_id, str(internal_id), ops=ops)
doc_result = await import_documents(
backend=backend,
@@ -399,17 +436,38 @@ async def import_bank(
)
async with acquire_with_retry(backend) as conn:
result.mental_models_imported = await _restore_rows(
conn, "mental_models", parsed.bank_rows.get("mental_models", [])
conn,
"mental_models",
parsed.bank_rows.get("mental_models", []),
bank_rows_json_encoding=bank_rows_json_encoding,
)
# 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", [])
conn,
"mental_model_history",
parsed.bank_rows.get("mental_model_history", []),
bank_rows_json_encoding=bank_rows_json_encoding,
)
result.directives_imported = await _restore_rows(
conn,
"directives",
parsed.bank_rows.get("directives", []),
bank_rows_json_encoding=bank_rows_json_encoding,
)
result.webhooks_imported = await _restore_rows(
conn,
"webhooks",
parsed.bank_rows.get("webhooks", []),
bank_rows_json_encoding=bank_rows_json_encoding,
)
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, []))
for table in HISTORY_TABLES:
result.history_rows_imported += await _restore_rows(
conn,
table,
parsed.history_rows.get(table, []),
bank_rows_json_encoding=bank_rows_json_encoding,
)
logger.info(
"[transfer] Imported bank %s: %d doc(s), %d fact(s), %d observation(s), "
@@ -461,8 +519,8 @@ async def _import_one_document(
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."""
) -> _ImportedFactBatch:
"""Re-embed and insert a document; map original fact ordinals to new unit ids."""
log_buffer: list[str] = []
# Fire the same retain.completed webhook retain emits, transactionally inside
@@ -474,12 +532,21 @@ async def _import_one_document(
)
extracted_facts = [_to_extracted_fact(fact) for fact in document.facts]
legacy_causal_relations = _legacy_causal_relations(document)
processed_facts: list[ProcessedFact] = []
retained_index_by_original: list[int | None] = []
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)]
fact_batch = orchestrator._process_extracted_facts(extracted_facts, embeddings)
extracted_facts = fact_batch.extracted_facts
processed_facts = fact_batch.processed_facts
retained_index_by_original = fact_batch.retained_index_by_original
legacy_causal_relations = orchestrator._remap_causal_relations(
legacy_causal_relations,
retained_index_by_original,
)
contents = [RetainContent(content=document.original_text or "")]
chunk_meta = [
@@ -515,6 +582,15 @@ async def _import_one_document(
document.tags,
ops=ops,
)
if document.created_at is not None:
# Transfer archives carry source provenance. Apply it here,
# without changing normal retain/upsert timestamp semantics.
await conn.execute(
f"UPDATE {fq_table('documents')} SET created_at = $1 WHERE id = $2 AND bank_id = $3",
document.created_at,
target_id,
bank_id,
)
chunk_id_map: dict[int, str] = {}
if chunk_meta:
@@ -536,7 +612,7 @@ async def _import_one_document(
processed_facts,
config,
log_buffer,
resolved_entity_ids=phase1.entities.resolved_entity_ids,
resolved_entities=phase1.entities.resolved_entities,
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,
@@ -545,14 +621,100 @@ async def _import_one_document(
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)
# Retain writes only ``caused_by``. Restore legacy archive edges
# separately so their distinct direction and semantics survive a
# transfer without broadening the normal retain write contract.
if result_unit_ids and legacy_causal_relations:
await link_utils.restore_legacy_causal_links_batch(
conn,
bank_id,
result_unit_ids[0],
legacy_causal_relations,
ops=ops,
)
# Restore the source consolidation lifecycle. A whole-bank transfer
# preserves exact eligibility: a fact that was consolidated (or that
# failed consolidation) in the source is never re-consolidated on the
# target, so the maintenance reconciler sees no phantom backlog and
# observations are not re-derived. Archives predating these fields
# carry None for all three -> skipped here, leaving the
# observation-driven marking in _import_observations as the only
# (lossy) signal, exactly as before.
if result_unit_ids:
await _restore_fact_lifecycle(
conn,
bank_id,
document.facts,
retained_index_by_original,
result_unit_ids[0],
)
# Best-effort, and only after the acquire() block above has exited: this
# takes its own connection, and on Oracle the write above is not committed
# until that block exits, so flushing while still holding the connection
# deadlocks (see the retain orchestrator for the full explanation).
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 []
# Single content item -> result_unit_ids[0] follows the retained fact order.
retained_unit_ids = list(result_unit_ids[0]) if result_unit_ids else []
return _ImportedFactBatch(
unit_ids=retained_unit_ids,
original_ordinals=[
original_index
for original_index, retained_index in enumerate(retained_index_by_original)
if retained_index is not None
],
)
async def _restore_fact_lifecycle(
conn: Any,
bank_id: str,
facts: list[TransferFact],
retained_index_by_original: list[int | None],
retained_unit_ids: list[str],
) -> None:
"""Apply each imported fact's source consolidation timestamps to its new row.
``retained_unit_ids`` follows the retained fact order; ``retained_index_by_original[i]``
maps original fact ``i`` to its position there (or ``None`` if it was dropped
on insert, e.g. a duplicate). ``created_at`` restores source provenance only
when present (mirroring the document-row handling); ``consolidated_at`` /
``consolidation_failed_at`` are set verbatim a source-``NULL`` (unconsolidated)
fact stays eligible, which is correct.
"""
rows: list[tuple[uuid.UUID, datetime | None, datetime | None, datetime | None]] = []
for original_index, fact in enumerate(facts):
retained_index = retained_index_by_original[original_index]
if retained_index is None:
continue
if fact.created_at is None and fact.consolidated_at is None and fact.consolidation_failed_at is None:
# Legacy archive without lifecycle fields — nothing to restore.
continue
rows.append(
(
uuid.UUID(retained_unit_ids[retained_index]),
fact.created_at,
fact.consolidated_at,
fact.consolidation_failed_at,
)
)
if not rows:
return
await conn.executemany(
f"UPDATE {fq_table('memory_units')} "
f"SET created_at = COALESCE($2, created_at), consolidated_at = $3, consolidation_failed_at = $4 "
f"WHERE id = $1 AND bank_id = $5",
[
(unit_id, created_at, consolidated_at, failed_at, bank_id)
for unit_id, created_at, consolidated_at, failed_at in rows
],
)
async def _import_observations(
@@ -622,16 +784,27 @@ async def _import_observations(
all_source_ids: set[uuid.UUID] = set()
for (obs, sources), obs_unit_id in zip(resolved, obs_unit_ids):
observation_uuid = uuid.UUID(obs_unit_id)
if obs.event_date is not None:
# insert_facts_batch derives event_date for normal writes;
# transfer restores the source value carried by the archive.
await conn.execute(
f"UPDATE {fq_table('memory_units')} SET event_date = $1 WHERE id = $2 AND bank_id = $3",
obs.event_date,
observation_uuid,
bank_id,
)
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
)
await _link_observation_sources(conn, ops, bank_id, observation_uuid, source_uuids, obs.proof_count)
# Mark source facts consolidated so the target consolidator skips them.
# Mark source facts consolidated so the target consolidator skips
# them. COALESCE keeps the exact source timestamp already restored by
# _restore_fact_lifecycle (new archives); now() is the fallback only
# for legacy archives that carry no per-fact lifecycle state.
if all_source_ids:
await conn.execute(
f"UPDATE {fq_table('memory_units')} SET consolidated_at = now() "
f"UPDATE {fq_table('memory_units')} SET consolidated_at = COALESCE(consolidated_at, now()) "
f"WHERE bank_id = $1 AND id = ANY($2)",
bank_id,
list(all_source_ids),
@@ -705,6 +878,7 @@ def _to_extracted_fact(fact: TransferFact) -> ExtractedFact:
causal_relations=[
CausalRelation(relation_type=rel.relation_type, target_fact_index=rel.target_fact_index)
for rel in fact.causal_relations
if rel.relation_type == CANONICAL_CAUSAL_LINK_TYPE
],
content_index=0,
chunk_index=fact.chunk_index,
@@ -714,3 +888,19 @@ def _to_extracted_fact(fact: TransferFact) -> ExtractedFact:
tags=list(fact.tags),
observation_scopes=fact.observation_scopes,
)
def _legacy_causal_relations(document: TransferDocument) -> list[list[CausalRelation]]:
"""Return legacy archive edges for transfer-only restoration.
Invalid archive values are excluded. The write helper repeats the explicit
compatibility allowlist as a persistence boundary.
"""
return [
[
CausalRelation(relation_type=relation.relation_type, target_fact_index=relation.target_fact_index)
for relation in fact.causal_relations
if relation.relation_type in LEGACY_CAUSAL_LINK_TYPES
]
for fact in document.facts
]
@@ -22,7 +22,14 @@ from pydantic import BaseModel, Field
# Bump when the archive layout changes in a backward-incompatible way.
SCHEMA_VERSION = 1
# Whole-bank transfer table classifications shared by export and import.
# Child history is always carried after its mental-model parent; operational
# history is optional and included only when the caller requests it.
CARRIED_HISTORY_TABLES = ("mental_model_history",)
HISTORY_TABLES = ("audit_log", "llm_requests")
ObservationScopes = Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]]
BankRowsJSONEncoding = Literal["decoded", "serialized"]
class TransferCausalRelation(BaseModel):
@@ -61,6 +68,15 @@ class TransferFact(BaseModel):
# 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)
# Consolidation lifecycle timestamps, carried verbatim by a whole-bank
# transfer so imported facts keep their exact consolidation eligibility: an
# already-consolidated or failed fact is never re-consolidated on the target,
# and the maintenance reconciler sees no phantom backlog. Absent in archives
# produced before these were added (-> None), in which case the importer
# falls back to marking only observation-referenced facts consolidated.
created_at: datetime | None = None
consolidated_at: datetime | None = None
consolidation_failed_at: datetime | None = None
class TransferChunk(BaseModel):
@@ -136,3 +152,7 @@ class TransferManifest(BaseModel):
webhook_count: int = 0
# True when --include-history carried audit_log / llm_requests.
includes_history: bool = False
# How JSON/JSONB values in bank/history row files were represented by the
# producing connection. Absent on legacy v1 archives; import treats those as
# decoded because the released producer was the codec-enabled admin CLI.
bank_rows_json_encoding: BankRowsJSONEncoding | None = None
@@ -0,0 +1,234 @@
"""Per-bank vector index coverage checks and repair.
Per-(bank, fact_type) partial vector indexes are created only when a bank is
first created (instant on an empty bank). A bank that becomes *populated*
outside that fresh-INSERT path via a logical restore, a cross-version upgrade,
or a vector-extension switch (e.g. ScaNNpgvector) never gets them, so its
bank-scoped recall silently falls back to the global index + post-filter, which
is both slower and under-returns results. See issue #2645.
This module is the shared engine for detecting and repairing that gap. It is
driven by the ``repair-bank`` admin command; the build always uses
``CREATE INDEX CONCURRENTLY`` on a raw autocommit connection so it never takes
``ACCESS EXCLUSIVE`` on the shared ``memory_units`` table.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any
from .db_utils import retry_with_backoff
from .retain.bank_utils import _BANK_INDEX_FACT_TYPES, _bank_index_name
logger = logging.getLogger(__name__)
# Postgres renders the partial predicate of an indexdef with parenthesized
# comparison operands and an explicit ::text cast, e.g.
# `... WHERE ((fact_type = 'world'::text) AND (bank_id = 'b1'::text))`.
# fact_type is emitted first (it is written first in the CREATE INDEX). Match
# that exact rendering so a mere name collision never counts as healthy.
_BANK_INDEX_PARTIAL_SUFFIX = " WHERE ((fact_type = "
# Access methods that legitimately back a per-(bank, fact_type) partial index.
# An index whose access method drifted after a backend switch does not match,
# so the health check treats it as unhealthy (rebuild).
_SUPPORTED_INDEX_AM: tuple[str, ...] = (
"btree",
"gin",
"gist",
"hnsw",
"ivfflat",
"diskann",
"vchordrq",
)
@dataclass
class SchemaVectorIndexResult:
"""Per-schema outcome of a vector-index repair pass."""
schema: str
banks_scanned: int = 0
already_present: int = 0
created: int = 0
skipped: int = 0 # would-create, reported under --dry-run
failed: int = 0
failed_indexes: list[str] = field(default_factory=list)
def _quote_identifier(value: str) -> str:
return '"' + value.replace('"', '""') + '"'
async def _index_health(conn: Any, schema: str, index_names: list[str]) -> dict[str, bool]:
"""Return valid-and-usable state for each requested index in one query.
Health requires the index to be valid AND ready, defined over the expected
``memory_units`` table, to use a supported access method, and to carry our
partial predicate. A name-only match is *not* enough: an INVALID leftover
(from an interrupted concurrent build) or an index whose access method
drifted after a backend switch must count as unhealthy so it is rebuilt
``pg_indexes``/``IF NOT EXISTS`` alone would silently treat those as present.
"""
if not index_names:
return {}
rows = await conn.fetch(
"""
SELECT c.relname AS index_name,
(i.indisvalid AND i.indisready
AND t.relname = 'memory_units'
AND am.amname = ANY($3::text[])
AND pg_get_indexdef(i.indexrelid) LIKE $4
) AS healthy
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_index i ON i.indexrelid = c.oid
JOIN pg_class t ON t.oid = i.indrelid
JOIN pg_am am ON am.oid = c.relam
WHERE n.nspname = $1 AND c.relname = ANY($2::text[])
""",
schema,
index_names,
list(_SUPPORTED_INDEX_AM),
"%" + _BANK_INDEX_PARTIAL_SUFFIX + "%",
)
return {row["index_name"]: bool(row["healthy"]) for row in rows}
async def _repair_schema(
conn: Any,
schema: str,
index_clause: str,
*,
dry_run: bool,
bank_id: str | None,
) -> SchemaVectorIndexResult:
result = SchemaVectorIndexResult(schema=schema)
qschema = _quote_identifier(schema)
if bank_id is not None:
banks = await conn.fetch(
f"SELECT bank_id, internal_id FROM {qschema}.banks WHERE bank_id = $1", # noqa: S608 — schema is a quoted identifier
bank_id,
)
else:
banks = await conn.fetch(f"SELECT bank_id, internal_id FROM {qschema}.banks ORDER BY bank_id") # noqa: S608
result.banks_scanned = len(banks)
# Resolve expected index names for every bank, then check them all in one
# catalog query rather than one round-trip per index.
expected_by_bank: list[tuple[str, dict[str, str]]] = []
all_index_names: list[str] = []
for bank in banks:
expected = {ft: _bank_index_name(ft, str(bank["internal_id"])) for ft in _BANK_INDEX_FACT_TYPES}
expected_by_bank.append((bank["bank_id"], expected))
all_index_names.extend(expected.values())
health = await _index_health(conn, schema, all_index_names)
for bid, expected in expected_by_bank:
# Render the bank_id literal server-side so escaping does not depend on
# standard_conforming_strings (the predicate is inlined into the DDL).
bank_id_literal = await conn.fetchval("SELECT quote_literal($1::text)", bid)
for ft in _BANK_INDEX_FACT_TYPES:
index_name = expected[ft]
healthy = health.get(index_name)
if healthy is True:
result.already_present += 1
continue
if dry_run:
result.skipped += 1
continue
qindex = _quote_identifier(index_name)
qualified = f"{qschema}.{qindex}"
async def _rebuild(
qindex: str = qindex,
qualified: str = qualified,
ft: str = ft,
bank_id_literal: str = bank_id_literal,
) -> None:
# Always drop first. An unhealthy-but-present index (INVALID
# leftover, wrong access method) can't be repaired by
# IF NOT EXISTS, and a prior deadlocked CONCURRENTLY build leaves
# an INVALID stub that IF NOT EXISTS would likewise skip — so a
# retry must clear it. DROP ... IF EXISTS is a no-op when the
# index is simply absent (healthy is None).
await conn.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {qualified}")
await conn.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {qindex} "
f"ON {qschema}.memory_units {index_clause} "
f"WHERE fact_type = '{ft}' AND bank_id = {bank_id_literal}"
)
try:
# CREATE INDEX CONCURRENTLY on the live, concurrently-written
# memory_units table can be chosen as a deadlock victim
# (sqlstate 40P01 / ORA-00060). That is transient — Postgres
# aborts one side to break the cycle — so retry the drop+build a
# few times before recording a permanent failure.
await retry_with_backoff(_rebuild)
result.created += 1
except Exception as exc: # noqa: BLE001 — one failed index must not abort the rest
result.failed += 1
result.failed_indexes.append(qualified)
logger.warning(
"Failed to repair vector index %s (bank=%s, fact_type=%s): %s"
"dropping the invalid leftover so a re-run can retry.",
qualified,
bid,
ft,
exc,
)
# A failed concurrent build leaves an INVALID index behind that
# would shadow the good one; drop it so a re-run retries cleanly.
try:
await conn.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {qualified}")
except Exception as cleanup_exc: # noqa: BLE001
logger.warning("Cleanup DROP INDEX for %s also failed: %s", qualified, cleanup_exc)
return result
async def _safe_repair_schema(
conn: Any,
schema: str,
index_clause: str,
*,
dry_run: bool,
bank_id: str | None,
) -> SchemaVectorIndexResult:
try:
return await _repair_schema(conn, schema, index_clause, dry_run=dry_run, bank_id=bank_id)
except Exception as exc: # noqa: BLE001 — one bad schema must not abort the whole sweep
logger.warning("Vector index repair aborted for schema %s: %s", schema, exc)
return SchemaVectorIndexResult(schema=schema, failed=1, failed_indexes=[f"{schema}.<schema-error>"])
async def repair_vector_indexes(
conn: Any,
schemas: list[str],
index_clause: str,
*,
dry_run: bool = False,
bank_id: str | None = None,
) -> list[SchemaVectorIndexResult]:
"""Rebuild missing or invalid per-bank vector indexes across ``schemas``.
``conn`` must be a raw autocommit PostgreSQL connection: ``CREATE INDEX
CONCURRENTLY`` cannot run inside a transaction block. When ``bank_id`` is
given, only that bank is reconciled (in each schema); otherwise every bank
is scanned.
Concurrency is handled by idempotency, not a lock (project rule: no advisory
locks they are unreliable behind connection poolers). Every build is
``CREATE INDEX CONCURRENTLY IF NOT EXISTS`` guarded by a valid/ready health
check, so a second concurrent run is a no-op on already-built indexes; if two
runs race the *same* missing index, Postgres rejects one build and the
per-index handler drops the leftover so a re-run converges cleanly.
"""
return [
await _safe_repair_schema(conn, schema, index_clause, dry_run=dry_run, bank_id=bank_id) for schema in schemas
]
@@ -15,6 +15,7 @@ Extensions receive an ExtensionContext that provides a controlled API for intera
with the system (e.g., running migrations for tenant schemas).
"""
from hindsight_api.extensions.bank_tables import BankScopedTable
from hindsight_api.extensions.base import Extension
from hindsight_api.extensions.builtin import (
ApiKeyTenantExtension,
@@ -45,6 +46,7 @@ from hindsight_api.extensions.operation_validator import (
# Consolidation operation
ConsolidateContext,
ConsolidateResult,
CreateBankContext,
# File Conversion
FileConvertResult,
# Mental Model operations
@@ -77,6 +79,7 @@ from hindsight_api.worker.exceptions import DeferOperation
__all__ = [
# Base
"Extension",
"BankScopedTable",
"load_extension",
# Context
"ExtensionContext",
@@ -105,6 +108,7 @@ __all__ = [
"BankReadOperation",
"BankWriteContext",
"BankWriteOperation",
"CreateBankContext",
# Operation Validator - Consolidation
"ConsolidateContext",
"ConsolidateResult",
@@ -0,0 +1,60 @@
"""Extension-declared, bank-scoped tables.
An extension may provision its own tables in the tenant schema (e.g. audit
receipts, per-bank policy state). Those tables are invisible to core, so they
silently fall out of the per-tenant data-lifecycle operations core owns:
* **Backup / restore** ``hindsight-admin backup``/``restore`` copies a fixed
set of core tables and ``TRUNCATE ... CASCADE``\\ s them on restore. An
extension table absent from that set is dropped from the backup *and* if it
carries a FK to ``banks`` wiped by the cascade with no way to restore it.
* **Bank teardown** :meth:`MemoryEngine.delete_bank` clears a bank by
deleting the core rows and letting ``banks``' FK cascade handle the rest. An
extension table that scopes by ``bank_id`` without a cascading FK leaks
orphaned rows when the bank is deleted.
An extension declares its bank-scoped tables via
:meth:`TenantExtension.extra_bank_tables`; core consults that list in the
operations above. The extension still owns the DDL (creation lives in its
provisioning path) this descriptor only tells core which tables to sweep.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
# Unquoted SQL identifiers only. ``name`` and ``bank_id_column`` are
# interpolated into SQL (schema-qualified via ``fq_table``), so they must be
# validated to a safe identifier shape rather than trusted verbatim.
_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
@dataclass(frozen=True)
class BankScopedTable:
"""A bank-scoped table an extension owns and core should sweep.
Args:
name: Unqualified table name. Schema-qualified at use via ``fq_table``.
bank_id_column: Column holding the bank id, used to scope a per-bank
delete. Defaults to ``"bank_id"``.
include_in_backup: Include the table in ``hindsight-admin``
backup/restore. Defaults to ``True`` a bank-scoped table almost
always wants restore coverage; opt out only for regenerable or
transient state.
delete_with_bank: Delete the table's rows for a bank during a full
:meth:`MemoryEngine.delete_bank`. Defaults to ``True``. Set
``False`` to retain rows that should outlive the bank (e.g. audit
receipts a compliance regime requires kept).
"""
name: str
bank_id_column: str = "bank_id"
include_in_backup: bool = True
delete_with_bank: bool = True
def __post_init__(self) -> None:
if not _IDENTIFIER_RE.match(self.name):
raise ValueError(f"BankScopedTable.name {self.name!r} is not a valid SQL identifier")
if not _IDENTIFIER_RE.match(self.bank_id_column):
raise ValueError(f"BankScopedTable.bank_id_column {self.bank_id_column!r} is not a valid SQL identifier")
@@ -160,6 +160,19 @@ class DefaultExtensionContext(ExtensionContext):
schema=schema,
)
# Provision any extension-owned bank-scoped tables for this schema,
# right after core migrations, so extension schema evolves on the same
# lifecycle as core schema (instead of via a lazy per-request path).
# No-op unless a tenant extension declares a provisioner; errors
# propagate so a failed provision surfaces here, not at request time.
engine = self._memory_engine
get_pool = getattr(engine, "_get_pool", None)
tenant_extension = getattr(engine, "tenant_extension", None)
if get_pool is not None and tenant_extension is not None:
pool = await get_pool()
async with pool.acquire() as conn:
await tenant_extension.provision_bank_tables(conn, schema)
def get_memory_engine(self) -> "MemoryEngineInterface":
"""Get the memory engine interface."""
if self._memory_engine is None:
@@ -359,6 +359,7 @@ class BankWriteOperation(StrEnum):
DELETE_DIRECTIVE = "delete_directive"
DELETE_DOCUMENT = "delete_document"
DELETE_MENTAL_MODEL = "delete_mental_model"
DELETE_OPERATION = "delete_operation"
DELETE_WEBHOOK = "delete_webhook"
MERGE_BANK_MISSION = "merge_bank_mission"
REPROCESS_DOCUMENT = "reprocess_document"
@@ -397,6 +398,14 @@ class BankWriteContext:
request_context: "RequestContext"
@dataclass
class CreateBankContext:
"""Context for validating creation of a new bank."""
bank_id: str
request_context: "RequestContext"
@dataclass
class BankListContext:
"""Context for filtering the bank list (post-query)."""
@@ -881,6 +890,23 @@ class OperationValidatorExtension(Extension, ABC):
"""
return ValidationResult.accept()
async def validate_create_bank(self, ctx: CreateBankContext) -> ValidationResult:
"""
Validate creation of a new bank before the bank row is inserted.
Override to implement custom validation logic for operations that
explicitly or implicitly create a bank.
Args:
ctx: Context containing:
- bank_id: Bank identifier
- request_context: Request context with auth info
Returns:
ValidationResult indicating whether the bank may be created.
"""
return ValidationResult.accept()
async def filter_bank_list(self, ctx: BankListContext) -> BankListResult:
"""
Filter the bank list after querying.
@@ -2,11 +2,15 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
from typing import TYPE_CHECKING, Any
from hindsight_api.extensions.bank_tables import BankScopedTable
from hindsight_api.extensions.base import Extension
from hindsight_api.models import RequestContext
if TYPE_CHECKING:
import asyncpg
class AuthenticationError(Exception):
"""Raised when authentication fails."""
@@ -143,6 +147,50 @@ class TenantExtension(Extension, ABC):
"""
return None
def extra_bank_tables(self) -> list[BankScopedTable]:
"""Bank-scoped tables this extension provisions in the tenant schema.
Core consults this list so extension-owned tables participate in the
per-tenant data-lifecycle operations it manages admin backup/restore
and :meth:`MemoryEngine.delete_bank` teardown instead of silently
falling out of them (dropped on restore, or leaked as orphaned rows on
bank deletion). See :class:`BankScopedTable`.
The extension still owns the DDL; this only declares which tables exist.
The default is no extra tables.
Returns:
The extension's bank-scoped tables. Empty by default.
"""
return []
async def provision_bank_tables(self, conn: "asyncpg.Connection", schema: str) -> None:
"""Create/evolve this extension's tables in ``schema`` (idempotent DDL).
Called from the **migration path** for every schema both when a
tenant schema is provisioned (via ``ExtensionContext.run_migration``)
and by the ``hindsight-admin run-db-migration`` sweep across all
existing schemas right after core migrations complete. This is the
counterpart to :meth:`extra_bank_tables`: that one *declares* the
tables for backup/teardown, this one *creates* them, so extension
schema evolves on the same lifecycle as core schema instead of via a
lazy per-request path.
Implementations MUST be idempotent (``CREATE TABLE IF NOT EXISTS`` /
``ADD COLUMN IF NOT EXISTS``) it runs on every provision and every
migration sweep and MUST schema-qualify every statement with
``schema`` (the connection's ``search_path`` is not set for you).
Exceptions propagate so a failed provision surfaces at migration time
rather than as a runtime error on a later request.
Args:
conn: An open connection to the target database.
schema: The schema to provision tables into.
The default does nothing.
"""
return None
async def authenticate_mcp(self, context: RequestContext) -> TenantContext:
"""
Authenticate MCP requests.
@@ -0,0 +1,147 @@
"""Event-loop stall watchdog.
Hindsight's worker and API run the ``/health`` handler and all task work on one
asyncio event loop. If something does blocking (synchronous) work on that loop
CPU-bound parsing, a mis-offloaded SDK call, a third-party library that signs a
request inline the loop stops servicing coroutines, ``/health`` can't be
scheduled, and a Kubernetes liveness probe fails even though the process is "up".
This watchdog makes that condition self-diagnosing. It runs in a **separate OS
thread** (deliberately: a coroutine-based monitor would be frozen by the very
stall it's trying to observe), pings the loop, and when the loop fails to service
the ping within a threshold it logs the loop thread's current stack — naming the
exact frame that is blocking. It never raises and never touches the loop's work;
it only observes. Unlike monkeypatch-based blocking detectors it works with
uvloop, because it relies only on ``loop.call_soon_threadsafe`` and
``sys._current_frames()``.
It is the loop-side counterpart to the DB-pool acquire instrumentation
(``engine/db/pool_instrumentation.py``): together they let a stuck ``/health`` be
attributed to either a blocked loop or connection-pool exhaustion from the logs
alone.
"""
from __future__ import annotations
import logging
import sys
import threading
import time
import traceback
from collections.abc import Callable
logger = logging.getLogger("hindsight.loop_watchdog")
def start_loop_watchdog(loop) -> "LoopWatchdog | None":
"""Build and start a watchdog for ``loop`` from config, or return None if disabled.
Call this once, from inside the running loop's process (worker CLI / API lifespan),
and call ``.stop()`` on the returned handle at shutdown.
"""
from .config import get_config
config = get_config()
if not config.loop_watchdog_enabled:
return None
watchdog = LoopWatchdog(
loop,
stall_threshold_s=config.loop_watchdog_stall_threshold_ms / 1000.0,
poll_interval_s=config.loop_watchdog_poll_interval_ms / 1000.0,
)
watchdog.start()
return watchdog
class LoopWatchdog:
"""Detects event-loop stalls from an off-loop thread and logs the culprit stack.
Args:
loop: the asyncio event loop to monitor.
stall_threshold_s: log when the loop takes at least this long to service a ping.
poll_interval_s: how often to ping the loop.
on_stall: optional callback ``(blocked_for_s, stack_text)`` invoked on each
detected stall instead of the default log+metric path. Used for testing.
"""
def __init__(
self,
loop,
*,
stall_threshold_s: float = 1.0,
poll_interval_s: float = 0.25,
on_stall: Callable[[float, str], None] | None = None,
) -> None:
self._loop = loop
self._stall_threshold_s = stall_threshold_s
self._poll_interval_s = poll_interval_s
self._on_stall = on_stall
self._stop = threading.Event()
self._loop_thread_id: int | None = None
self._thread = threading.Thread(target=self._run, name="loop-watchdog", daemon=True)
self._started = False
def start(self) -> None:
"""Start monitoring. Must not block the loop — the id is captured via pings.
When called from the loop thread itself (the normal case: worker ``run()`` /
API lifespan), ``threading.get_ident()`` is already the loop thread id, so we
seed it here; each ping then re-affirms it authoritatively. We deliberately do
NOT schedule-and-wait for a callback: that would deadlock, because the loop
can't run the callback while ``start()`` is blocking it.
"""
self._loop_thread_id = threading.get_ident()
self._started = True
self._thread.start()
logger.info(
"Loop watchdog started (stall_threshold=%.2fs, poll_interval=%.2fs)",
self._stall_threshold_s,
self._poll_interval_s,
)
def stop(self) -> None:
self._stop.set()
if self._started and self._thread.is_alive():
self._thread.join(timeout=self._poll_interval_s + self._stall_threshold_s + 1.0)
def _run(self) -> None:
while not self._stop.wait(self._poll_interval_s):
serviced = threading.Event()
sent_at = time.monotonic()
def _ping() -> None:
# Runs on the loop thread — capture its id authoritatively, then
# signal that the loop serviced this ping.
self._loop_thread_id = threading.get_ident()
serviced.set()
try:
self._loop.call_soon_threadsafe(_ping)
except RuntimeError:
return # loop closed — nothing left to watch
if not serviced.wait(self._stall_threshold_s):
self._report(sent_at)
# Block until the loop finally services the ping so we emit one
# report per stall, not one per poll while it stays blocked.
serviced.wait()
def _report(self, sent_at: float) -> None:
frame = sys._current_frames().get(self._loop_thread_id or -1)
stack = "".join(traceback.format_stack(frame)) if frame is not None else "<loop-thread frame unavailable>"
blocked_for = time.monotonic() - sent_at
if self._on_stall is not None:
self._on_stall(blocked_for, stack)
return
logger.warning(
"EVENT LOOP BLOCKED for >= %.2fs (%.2fs and counting). The loop is not "
"servicing coroutines — /health cannot be scheduled. Blocking frame:\n%s",
self._stall_threshold_s,
blocked_for,
stack,
)
try:
from .metrics import get_metrics_collector
get_metrics_collector().record_loop_stall(blocked_for)
except Exception:
pass
+3
View File
@@ -32,6 +32,7 @@ from .config import (
ENV_WORKERS,
HindsightConfig,
_get_raw_config,
load_dotenv_for_entrypoint,
)
from .daemon import (
DEFAULT_DAEMON_PORT,
@@ -197,6 +198,8 @@ def main():
"""Main entry point for the CLI."""
global _memory
load_dotenv_for_entrypoint()
# Load configuration from environment (for CLI args defaults)
config = _get_raw_config()
+76 -29
View File
@@ -9,7 +9,7 @@ import json
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Callable
from typing import Any, Callable, get_args
from fastmcp import FastMCP
from mcp.types import ToolAnnotations
@@ -23,7 +23,7 @@ from hindsight_api.config import (
from hindsight_api.engine.audit import AuditEntry, AuditLogger
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, MinScores
from hindsight_api.engine.search.tags import TagGroup
from hindsight_api.engine.search.tags import TagGroup, TagsMatch
from hindsight_api.extensions import OperationValidationError
from hindsight_api.models import RequestContext
@@ -512,7 +512,8 @@ def _apply_audit_logging(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCon
"""Create an audited wrapper for a tool's run method."""
async def _audited_run(arguments, _name=tool_name, _orig=original_run):
if not audit_logger.is_enabled(_name):
# Cheap bank-independent pre-filter before resolving bank_id.
if not audit_logger.action_allowed(_name):
return await _orig(arguments)
bank_id = None
@@ -521,6 +522,10 @@ def _apply_audit_logging(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCon
elif hasattr(arguments, "get"):
bank_id = arguments.get("bank_id")
# Per-bank decision, resolved after bank_id is known.
if not await audit_logger.should_log(_name, bank_id):
return await _orig(arguments)
entry = AuditEntry(
action=_name,
transport="mcp",
@@ -558,7 +563,8 @@ def _apply_audit_logging(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCon
if original_call_tool:
async def _audited_call_tool(name, arguments=None, **kwargs):
if name not in _AUDITABLE_MCP_TOOLS or not audit_logger.is_enabled(name):
# Cheap bank-independent pre-filter before resolving bank_id.
if name not in _AUDITABLE_MCP_TOOLS or not audit_logger.action_allowed(name):
return await original_call_tool(name, arguments, **kwargs)
bank_id = None
@@ -567,6 +573,10 @@ def _apply_audit_logging(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCon
config.bank_id_resolver() if config.bank_id_resolver else None
)
# Per-bank decision, resolved after bank_id is known.
if not await audit_logger.should_log(name, bank_id):
return await original_call_tool(name, arguments, **kwargs)
entry = AuditEntry(
action=name,
transport="mcp",
@@ -1225,18 +1235,16 @@ def _register_create_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
"""
try:
request_context = _get_request_context(config)
# get_bank_profile auto-creates bank if it doesn't exist
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
# Update name/mission if provided
if name is not None or mission is not None:
await memory.update_bank(
profile = await memory.update_bank(
bank_id,
name=name,
mission=mission,
request_context=request_context,
)
# Fetch updated profile
else:
# The public profile API owns bank creation and its lifecycle
# validation when no profile fields need updating.
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
# Serialize disposition if it's a Pydantic model
@@ -1252,7 +1260,10 @@ def _register_create_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
def _validate_mental_model_inputs(
name: str | None = None, source_query: str | None = None, max_tokens: int | None = None
name: str | None = None,
source_query: str | None = None,
max_tokens: int | None = None,
tags_match: str | None = None,
) -> str | None:
"""Validate mental model inputs, returning an error message or None if valid."""
if name is not None and not name.strip():
@@ -1261,6 +1272,9 @@ def _validate_mental_model_inputs(
return "source_query cannot be empty"
if max_tokens is not None and (max_tokens < 256 or max_tokens > 8192):
return f"max_tokens must be between 256 and 8192, got {max_tokens}"
if tags_match is not None and tags_match not in get_args(TagsMatch):
valid = ", ".join(get_args(TagsMatch))
return f"tags_match must be one of {valid}, got {tags_match!r}"
return None
@@ -1442,6 +1456,7 @@ def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC
source_query: str,
mental_model_id: str | None = None,
tags: list[str] | None = None,
tags_match: str | None = None,
max_tokens: int = 2048,
trigger_refresh_after_consolidation: bool = False,
bank_id: str | None = None,
@@ -1463,6 +1478,12 @@ def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC
source_query: The query to run through reflect to generate content
mental_model_id: Optional custom ID (alphanumeric lowercase with hyphens). Auto-generated if not provided.
tags: Optional tags for scoped visibility filtering
tags_match: How this model's tags are matched against memories when the content
is (re)generated. One of 'any' (match any tag, like recall/reflect), 'all'
(match all tags), 'any_strict', 'all_strict', or 'exact'. If omitted, a tagged
model defaults to 'all_strict' a memory must carry EVERY one of the model's
tags to be included, which silently filters out memories that only carry a
subset. Pass 'any' when your memories use narrow single-topic tags.
max_tokens: Maximum tokens for generated content (256-8192, default: 2048)
trigger_refresh_after_consolidation: If True, automatically refresh this model after memory consolidation. Default: False
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
@@ -1473,13 +1494,15 @@ def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC
return '{"error": "No bank_id configured"}'
validation_error = _validate_mental_model_inputs(
name=name, source_query=source_query, max_tokens=max_tokens
name=name, source_query=source_query, max_tokens=max_tokens, tags_match=tags_match
)
if validation_error:
return json.dumps({"error": validation_error})
request_context = _get_request_context(config)
trigger = {"refresh_after_consolidation": trigger_refresh_after_consolidation}
trigger: dict[str, Any] = {"refresh_after_consolidation": trigger_refresh_after_consolidation}
if tags_match is not None:
trigger["tags_match"] = tags_match
# Create with placeholder content
model = await memory.create_mental_model(
@@ -1526,6 +1549,7 @@ def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC
source_query: str,
mental_model_id: str | None = None,
tags: list[str] | None = None,
tags_match: str | None = None,
max_tokens: int = 2048,
trigger_refresh_after_consolidation: bool = False,
) -> dict:
@@ -1546,6 +1570,12 @@ def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC
source_query: The query to run through reflect to generate content
mental_model_id: Optional custom ID (alphanumeric lowercase with hyphens). Auto-generated if not provided.
tags: Optional tags for scoped visibility filtering
tags_match: How this model's tags are matched against memories when the content
is (re)generated. One of 'any' (match any tag, like recall/reflect), 'all'
(match all tags), 'any_strict', 'all_strict', or 'exact'. If omitted, a tagged
model defaults to 'all_strict' a memory must carry EVERY one of the model's
tags to be included, which silently filters out memories that only carry a
subset. Pass 'any' when your memories use narrow single-topic tags.
max_tokens: Maximum tokens for generated content (256-8192, default: 2048)
trigger_refresh_after_consolidation: If True, automatically refresh this model after memory consolidation. Default: False
"""
@@ -1555,13 +1585,15 @@ def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC
return {"error": "No bank_id configured"}
validation_error = _validate_mental_model_inputs(
name=name, source_query=source_query, max_tokens=max_tokens
name=name, source_query=source_query, max_tokens=max_tokens, tags_match=tags_match
)
if validation_error:
return {"error": validation_error}
request_context = _get_request_context(config)
trigger = {"refresh_after_consolidation": trigger_refresh_after_consolidation}
trigger: dict[str, Any] = {"refresh_after_consolidation": trigger_refresh_after_consolidation}
if tags_match is not None:
trigger["tags_match"] = tags_match
model = await memory.create_mental_model(
bank_id=target_bank,
@@ -2243,6 +2275,8 @@ def _register_list_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
limit: int = 100,
offset: int = 0,
bank_id: str | None = None,
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
) -> str:
"""
Browse stored memories with optional filtering.
@@ -2256,6 +2290,10 @@ def _register_list_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
limit: Maximum number of results (default: 100)
offset: Pagination offset (default: 0)
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
tags: Optional list of tag names to filter by.
tags_match: How to combine tags: 'any' (OR, default) or 'all' (AND)
both also include untagged memories; 'any_strict'/'all_strict'
exclude untagged; 'exact' matches the tag set exactly.
"""
try:
target_bank = bank_id or config.bank_id_resolver()
@@ -2268,6 +2306,8 @@ def _register_list_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
search_query=q,
limit=limit,
offset=offset,
tags=tags,
tags_match=tags_match,
request_context=_get_request_context(config),
)
return json.dumps(result, indent=2, default=str)
@@ -2286,6 +2326,8 @@ def _register_list_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
q: str | None = None,
limit: int = 100,
offset: int = 0,
tags: list[str] | None = None,
tags_match: TagsMatch = "any",
) -> dict:
"""
Browse stored memories with optional filtering.
@@ -2298,6 +2340,10 @@ def _register_list_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
q: Optional text search query to filter memories
limit: Maximum number of results (default: 100)
offset: Pagination offset (default: 0)
tags: Optional list of tag names to filter by.
tags_match: How to combine tags: 'any' (OR, default) or 'all' (AND)
both also include untagged memories; 'any_strict'/'all_strict'
exclude untagged; 'exact' matches the tag set exactly.
"""
try:
target_bank = config.bank_id_resolver()
@@ -2310,6 +2356,8 @@ def _register_list_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPTools
search_query=q,
limit=limit,
offset=offset,
tags=tags,
tags_match=tags_match,
request_context=_get_request_context(config),
)
return result
@@ -3145,7 +3193,10 @@ def _register_get_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfi
profile = await memory.get_bank_profile(
target_bank,
request_context=_get_request_context(config),
create_if_missing=False,
)
if profile is None:
return json.dumps({"error": f"Bank '{target_bank}' not found"})
if "disposition" in profile and hasattr(profile["disposition"], "model_dump"):
profile["disposition"] = profile["disposition"].model_dump()
return json.dumps(profile, indent=2, default=str)
@@ -3173,7 +3224,10 @@ def _register_get_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfi
profile = await memory.get_bank_profile(
target_bank,
request_context=_get_request_context(config),
create_if_missing=False,
)
if profile is None:
return {"error": f"Bank '{target_bank}' not found"}
if "disposition" in profile and hasattr(profile["disposition"], "model_dump"):
profile["disposition"] = profile["disposition"].model_dump()
return profile
@@ -3232,28 +3286,21 @@ async def _do_update_bank(
Args:
name: Display name (stored in banks table).
mission: Deprecated alias for reflect_mission mapped into config_updates.
config_updates: Arbitrary config overrides passed to config_resolver.update_bank_config().
config_updates: Arbitrary config overrides passed to MemoryEngine.update_bank_config().
Supports all configurable fields (retain_mission, disposition_*, etc.).
The config resolver validates keys and rejects non-configurable/credential fields.
"""
# Update display name via engine (stored in DB banks table)
if name is not None:
await memory.update_bank(
target_bank,
name=name,
request_context=request_context,
)
# Merge deprecated mission alias into config_updates as reflect_mission
effective_config: dict[str, Any] = dict(config_updates) if config_updates else {}
if mission is not None and "reflect_mission" not in effective_config:
effective_config["reflect_mission"] = mission
if effective_config:
await memory._config_resolver.update_bank_config(target_bank, effective_config, request_context)
# Return updated profile
return await memory.get_bank_profile(target_bank, request_context=request_context)
return await memory.update_bank(
target_bank,
name=name,
config_updates=effective_config or None,
request_context=request_context,
)
def _register_update_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
@@ -292,6 +292,14 @@ class MetricsCollectorBase:
"""Context manager to record HTTP request metrics."""
raise NotImplementedError
def record_db_acquire_wait(self, wait_seconds: float):
"""Record how long a caller waited to acquire a pooled DB connection."""
raise NotImplementedError
def record_loop_stall(self, stall_seconds: float):
"""Record a detected event-loop stall (blocked longer than the watchdog threshold)."""
raise NotImplementedError
def set_db_pool(self, pool: "asyncpg.Pool"):
"""Set the database pool for metrics collection."""
pass
@@ -345,6 +353,14 @@ class NoOpMetricsCollector(MetricsCollectorBase):
"""No-op HTTP request recording."""
yield
def record_db_acquire_wait(self, wait_seconds: float):
"""No-op DB acquire-wait recording."""
pass
def record_loop_stall(self, stall_seconds: float):
"""No-op loop-stall recording."""
pass
class MetricsCollector(MetricsCollectorBase):
"""
@@ -426,6 +442,25 @@ class MetricsCollector(MetricsCollectorBase):
unit="requests",
)
# Runtime-stall observability: how long callers wait for a pooled DB
# connection (pool-exhaustion signal) and detected event-loop stalls
# (blocked-loop signal). See loop_watchdog.py and db/pool_instrumentation.py.
self.db_acquire_wait = self.meter.create_histogram(
name="hindsight.db.pool.acquire_wait",
description="Time spent waiting to acquire a pooled database connection",
unit="s",
)
self.event_loop_stalls = self.meter.create_counter(
name="hindsight.event_loop.stalls",
description="Number of detected event-loop stalls (loop blocked past the watchdog threshold)",
unit="stalls",
)
self.event_loop_stall_duration = self.meter.create_histogram(
name="hindsight.event_loop.stall_duration",
description="Duration of detected event-loop stalls in seconds",
unit="s",
)
# Process metrics (observable gauges - collected on scrape)
self._setup_process_metrics()
@@ -646,6 +681,15 @@ class MetricsCollector(MetricsCollectorBase):
# Decrement in-progress
self.http_requests_in_progress.add(-1, base_attributes)
def record_db_acquire_wait(self, wait_seconds: float):
"""Record how long a caller waited to acquire a pooled DB connection."""
self.db_acquire_wait.record(wait_seconds)
def record_loop_stall(self, stall_seconds: float):
"""Record a detected event-loop stall. Called from the watchdog thread."""
self.event_loop_stalls.add(1)
self.event_loop_stall_duration.record(stall_seconds)
def _setup_process_metrics(self):
"""Set up observable gauges for process metrics."""
if _resource_mod is None:
@@ -771,6 +815,20 @@ class MetricsCollector(MetricsCollectorBase):
except Exception:
pass
def get_pool_waiting(_options):
"""Number of callers currently blocked waiting to acquire a connection.
asyncpg does not expose this; it's tracked in db/pool_instrumentation.py.
This is the gauge that actually distinguishes pool exhaustion (a high,
sustained value) from a merely busy-but-healthy pool.
"""
try:
from .engine.db.pool_instrumentation import waiting_count
yield metrics.Observation(waiting_count())
except Exception:
pass
# Create observable gauges for pool metrics
self.meter.create_observable_gauge(
name="hindsight.db.pool.size",
@@ -800,6 +858,13 @@ class MetricsCollector(MetricsCollectorBase):
unit="{connections}",
)
self.meter.create_observable_gauge(
name="hindsight.db.pool.waiting",
callbacks=[get_pool_waiting],
description="Callers currently blocked waiting to acquire a pooled connection",
unit="{connections}",
)
def _setup_backlog_metrics(self):
"""Observable gauges for the async-operation queue and the
consolidation backlog.

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