Compare commits

...
Author SHA1 Message Date
Ben 82c6db52ad feat(agent-plugin): add portable Hindsight plugin for the Agent Plugins standard
Add a vendor-neutral Hindsight plugin conforming to Vercel's Agent Plugins
1.0.0 standard (plugin.json + mcp.json + skills/SKILL.md), so one artifact
gives long-term memory to any compatible client (Codex, Cursor, GitHub
Copilot, Kiro, VS Code) instead of a per-IDE integration. The plugin is a
thin transport wrapper over Hindsight's existing MCP server (retain / recall
/ reflect); a bundled skill teaches the agent when to use it.

Wiring:
- CI: test-agent-plugin-integration job runs the manifest validator, gated on
  hindsight-integrations/agent-plugin/** changes.
- Docs: integrations.json gallery entry + docs-integrations/agent-plugin.md.
- Release: agent-plugin added to release-integration.sh and the changelog
  generator; both learn to read a root-level plugin.json and link the
  changelog to the source tree (git-distributed bundle, no registry package).
2026-08-11 11:21:54 -04:00
Nicolò Boschi 475895f0a2 fix(retain): fold shared document_id items on the sync path (#3363) (#3386)
The sync retain batch endpoint rejected any batch whose items shared a
document_id, contradicting the RetainRequest schema/example, the
MemoryItem.document_id docs, and the SDK's batch-level documentId (which
inlines one id into every item). The guard existed to avoid a race, but
that race is only real on the queued path, where children fan out to
parallel workers. The synchronous path processes sub-batches sequentially.

retain_batch_async now folds items sharing an explicit document_id into
one document, in request order, running each document in a single
orchestrator pass. A single pass is required: splitting one document
across sub-batches that carry different bodies trips the streaming
pipeline's content-hash ownership check and silently drops later
sub-batches. Batches with no shared document_id are unchanged.

The queued path (submit_async_retain) keeps the guard, with a message
that points clients at async=false for folding.
2026-08-11 16:17:56 +02:00
Nicolò Boschi efc179f715 feat(memories): per-bank store capabilities on main (#3388)
Re-applies the per-bank store-capability seam onto current main. The pluggable
memories backend (#2917) is on main, but the per-bank capabilities landed later
on feat/pluggable-memories-provider (#3350, plus fix #3381) while main advanced
~179 commits.

A pluggable memories store may keep memory rows outside SQL and/or own the
document store. The process-level flags writes_memory_rows_in_sql /
owns_document_store gain per-bank forms — writes_memory_rows_in_sql_for(bank_id)
and owns_document_store_for(bank_id), defaulting to the class attrs — and every
bank-scoped call site in memory_engine, consolidation/consolidator, retain/* and
reflect/tools consults the per-bank form. Process-level maintenance gates keep
reading the class attr.

Also two NameError fixes of the same class (a bare bank_id where the in-scope
variable differs): list_banks (row["bank_id"], originally #3381) and get_chunk
(chunk["bank_id"], newly surfaced by pyflakes while rebasing).

Conflict resolution: only consolidation/consolidator.py conflicted — main added
consolidation sites since the branch; all are bank_id-scoped, so all convert to
the per-bank form.

Validation: pyflakes on all changed engine files reports 0 undefined names;
py_compile clean; per-bank + list_banks unit tests included.
2026-08-11 16:00:23 +02:00
Nicolò Boschi 7b35d2c6f2 feat(llm): opt-in forced-tool structured output for LiteLLM providers (#3300) (#3382)
* feat(llm): opt-in forced-tool structured output for LiteLLM providers (#3300)

Bedrock Claude rejects the structured-output route Hindsight uses. LiteLLM sends
a well-formed Converse `outputConfig`; Bedrock's Anthropic layer rewrites it to
snake_case internally and its own validator then refuses the key:

    BedrockException - {"message": "The model returned the following errors:
    output_config.format: Extra inputs are not permitted"}

Every `LiteLLMLLM.call()` with a `response_format` fails on that provider, so
retain returns 500 and consolidation degrades to "skipping batch" with no API
error at all. Reflect is unaffected because it goes through `call_with_tools()`,
which emits `toolConfig` — and Bedrock accepts that. The reporter's boto3 repro
isolates it to the transport, not the schema: the same trivial schema fails via
`outputConfig` and succeeds via `toolConfig`.

This is a different failure from the two earlier Bedrock schema fixes (#1289
`minimum`/`maximum`, #2500 `maxItems`). Those leaked one unsupported keyword into
an otherwise-accepted request and were fixed by not emitting it; here the whole
`response_format` route is refused, so no amount of schema sanitizing helps.

HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL (default false) makes the
LiteLLM-backed providers — litellm, litellmrouter, bedrock — ask for structured
output the way `anthropic_llm.py` already does natively: one tool whose
parameters are the response schema, forced via tool_choice. The tool call's
arguments are substituted for the message content before the existing parse
block, so markdown-stripping, `parse_llm_json` repair, retries, usage accounting
and tracing are untouched. If the model answers without calling the tool — a
gateway that drops tool_choice — the text is parsed as before.

Default false because every other LiteLLM backend handles `response_format`
natively; this only pays off where the backend refuses it.

* docs(llm): record the verified Bedrock behaviour behind the forced-tool flag

Reproduced on a real AWS account. In ap-southeast-2 with the au.* inference
profile, raw boto3 converse (no litellm at all) refuses the outputConfig
structured-output route while accepting the identical schema via toolConfig; the
same model in us-east-1 with us.* accepts both. So this is region/inference-profile
dependent, not "Bedrock Claude is broken" — which is why the flag stays opt-in
rather than being keyed off the provider.

Also: the rejected key comes back as `model: Extra inputs are not permitted`, not
the `output_config.format:` the issue quotes. Same validator and signature, but
operators grepping for that exact string would not find it, so the docs now name
the behaviour instead of the key.
2026-08-11 14:44:16 +02:00
Nicolò Boschi d4ac97d643 docs: correct the Azure OpenAI base URL (#3385)
Reported in #3377 and verified against a live Azure OpenAI resource.

The OpenAI-compatible tip told users to point HINDSIGHT_API_LLM_BASE_URL at
"your provider's endpoint", which for Azure reads as the resource root -- and
Azure does not serve the API there, so it returns 404 Resource not found.
Measured against a real resource (gpt-5-mini deployment):

  https://<res>.openai.azure.com                              404
  https://<res>.openai.azure.com/openai/deployments/<dep>     404 (no api-version)
  https://<res>.openai.azure.com/openai/v1                    works
  .../openai/deployments/<dep>?api-version=2025-01-01-preview works

Adds an Azure OpenAI Setup section with both working shapes and the three
things that actually bite: the model is the *deployment* name, the key is the
resource key (an APIM subscription key is a different setup), and gateways
must preserve the path shape.

Also records that Azure accepts the prompt_cache_key field sent under
cache_affinity=auto (#3271) on every api-version from 2024-02-01 onward, so
that default needs no Azure carve-out -- an explicitly untested risk when
#3271 merged, now closed.
2026-08-11 13:45:51 +02:00
Nicolò Boschi a2b018dce7 fix(retain): sweep observations when delta retain deletes chunks (#3384)
Delta retain drops a document's outgoing facts by deleting their chunks and
letting the FK cascade take the memory_units with them. Nothing swept the
observations derived from those facts: the sweep lives in
handle_document_tracking, which only the full-replace path calls. Every
re-ingest that took the delta path — a small edit to an existing document,
exactly what delta retain is for — therefore left the observations of the
changed chunks behind, still valid and still recallable, pointing at
source_memory_ids that no longer resolved.

Those rows were unreachable afterwards: consolidation batches are built from
facts, so an observation whose sources are all gone is never selected into a
batch again, and no runtime path deletes it.

Sweep in delete_chunks_by_ids, before the cascade, so the invariant holds at
the choke point rather than at one call site. It returns the number it
invalidated and the delta log line now carries that count unconditionally —
"the sweep matched nothing" and "the sweep never ran" were indistinguishable
from the outside, which is what made this hard to diagnose.

The sweep is keyed on the deleted chunks, not the document, so an edit to one
chunk leaves the other chunks' observations alone instead of requeueing the
whole document for consolidation.

Supersedes #3302. Reported and diagnosed by @fhiltscher.

Fixes #3294.
2026-08-11 13:26:41 +02:00
Nicolò Boschi f37cb0c799 release(coding-agents): v0.2.1 2026-08-11 13:13:19 +02:00
Parafee41andNicolò Boschi 78f1a0ef0c fix deletion of failed document uploads (#3366)
* fix deletion of failed document uploads

* fix(control-plane): swallow delete errors on failed upload rows

deleteFailedUpload only had try/finally, but fetchApi both toasts and
rethrows, and the handler is invoked from onClick without being awaited —
a failed delete left an unhandled promise rejection. Match the sibling
handlers in bank-operations-view, which catch and rely on the API client
interceptor for the user-facing error.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-11 13:09:50 +02:00
Nicolò Boschi f103949333 fix(coding-agents): drop Claude Code's compaction summary from retained turns (#3383)
Found investigating #3379, which asked for a PreCompact hook to avoid losing
context at compaction. The premise does not hold — compaction APPENDS a summary
record and leaves every earlier record in the transcript (verified on a real
11,144-record session compacted twice: 3,312 records precede the first marker and
are still there), and Stop fires after every assistant response, so the content
was already retained before compaction ran.

The investigation turned up the opposite problem. That summary record is a plain
type:"user" line with no isMeta flag, so nothing filtered it and Claude Code's
machine-written recap was retained as something the user said: 29 records /
474,016 chars across local transcripts, averaging 16KB each. Worse than
misattribution, it summarises turns ALREADY retained, so the same decisions get
extracted a second time from the recap.

Dropped alongside isMeta and isSidechain. Verified against the session that
surfaced it: 2886 -> 2884 turns, exactly the two summaries gone, every other turn
byte-identical.

Also corrects retain-cursor.ts, which cited Claude Code compaction as an example
of a transcript being rewritten rather than extended. It is not: the prefix stays
intact, so the append path keeps working through a compaction. The guard is
unchanged — only the example it named was wrong.
2026-08-11 13:04:21 +02:00
Nicolò Boschi d7c33fdeaf fix(transfer): mint a fresh internal_id on whole-bank import (#3270) (#3353)
Exporting a bank and importing it back into a new id on the same instance
failed with a ForeignKeyViolationError on mental_models.bank_id.

The banks row carries a globally-unique internal_id (banks_internal_id_unique,
used only for per-bank index naming). Import remapped only bank_id, so the copy
inherited the source bank's internal_id. On a same-instance re-import the source
row is still present, so the banks INSERT hit the unique constraint and
ON CONFLICT DO NOTHING silently skipped the parent row - then every child
(mental_models, directives, webhooks) tripped its bank_id foreign key.

Drop internal_id from the banks row before restore so the column DEFAULT
(gen_random_uuid) mints a fresh one. It is a local identifier that nothing in
the archive references, and create_bank_vector_indexes already reads it back
from the DB after insert. Cross-instance migration was unaffected (random UUIDs
don't collide); only the same-instance copy flow broke.

Adds a regression test that imports a bank (with a mental model) into a new id
without deleting the source and asserts the copy lands with a fresh internal_id.
2026-08-11 13:00:35 +02:00
Nicolò Boschi 4d9d31862e release(coding-agents): v0.2.0 2026-08-11 12:50:26 +02:00
Nicolò Boschi 291d2b1d9d docs(coding-agents): say which harnesses retainEveryTurns applies to (#3380)
It has exactly one consumer, RuntimeCore — the persistent-plugin harnesses
(opencode, Kilo, Cline CLI), which stay loaded and therefore choose when to write
back. The hook harnesses ignore it, and not by oversight: the host decides when
they run, and each Stop is a fresh process with no memory of how many turns have
passed.

Neither the config comment nor the settings table said so. Its neighbour
retainSessions does spell out the same split ("hook harnesses always write on
Stop"), so the silence read as "applies everywhere" — a Claude Code user setting
retainEveryTurns: 5 gets no effect and no warning. That is the workaround #2143's
reporter reached for, so the gap was load-bearing.

The README also said "opencode", which undersold it: Kilo and Cline CLI share
that runtime.
2026-08-11 12:44:18 +02:00
Nicolò Boschi 3b42913a64 fix(coding-agents): drop harness transport wrappers from retained turns (#3378)
Closes #3023.

Claude Code delivers <task-notification> as an ordinary type:"user" message —
string body, no isMeta flag — so nothing filtered it and fact extraction saw the
harness's background-task plumbing (task id, tool-use id, status, summary) as
something the user said. Measured across 400 local transcripts: 39 such messages,
16,289 chars, every one of them the entire message.

Reproduced with the real reader on a real transcript (243 retained turns, 1 of
them a task-notification carrying 519 chars of transport), and verified after:
242 turns, 0 noise, every genuine turn preserved.

The tag joins MEMORY_TAG_RE, which already covered codex's <hook_prompt> for
exactly this reason. <system-reminder> joins it too: today those only ride inside
tool_result blocks, which this reader drops entirely, so it is insurance against
the harness moving them — the rule is tag-structural, not content-guessing.

Note what the issue asked for and this does NOT do. Its two named cases are
already handled here: skill bodies arrive with isMeta:true (8/8 in the sample)
and are dropped with every other meta line, and <system-reminder> is inside a
dropped tool_result. Only the third case was live.

Stripping removes the BLOCK and keeps surrounding text, so a message that is
nothing but a wrapper renders empty and is dropped as a no-content turn, while a
real message mentioning one keeps the user's words — the mistake the old plugin's
unanchored strip_channel_envelope made (#3124).
2026-08-11 12:19:55 +02:00
github-actions[bot] a131622e63 chore: update star history 2026-08-11 03:55:26 +00:00
Ben 899c07e6c4 release(obsidian): v0.2.1 2026-08-10 15:19:42 -04:00
Ben ea81cf3450 fix(obsidian): scope sync index to bank + API target (#3257) (#3354)
hindsight-obsidian-sync keyed its sync index only on the vault name
(~/.hindsight/obsidian/<vault>.json) with a {syncIndex, lastSyncAt}
envelope carrying no destination identity. Pointing the CLI at a
different --bank or --api-url while reusing the default index made it
treat files synced to the old target as "unchanged", silently leaving
the new bank incomplete and potentially issuing prune DELETEs against a
bank it never wrote to.

Bind the index to its destination identity (canonical API origin, bank,
resolved vault path, vault name, prefix-doc-id):

- defaultIndexPath is now target-scoped: <vault>-<bank>-<fingerprint>.json,
  so different targets never share a default file.
- The envelope records the identity; loadIndex fails closed with an
  actionable IndexIdentityError (naming the changed field) when the
  persisted destination differs, and refuses legacy indexes with no
  identity metadata rather than silently trusting them.

Include/exclude scope is deliberately not bound: on the same destination
narrowing scope legitimately reuses the index and prunes newly-excluded
notes it owns there. Every harm in the issue requires a destination
change, which is what this refuses.

Adds regression tests for cross-bank/cross-API refusal end-to-end,
per-field mismatch, legacy-index refusal, target-scoped default paths,
and canonicalApiOrigin credential/path stripping.
2026-08-10 15:16:19 -04:00
Nicolò Boschi 00b520e592 feat(transfer): async document export (#3321) (#3340)
* feat(transfer): async document export (issue #3321)

The synchronous GET /banks/{id}/document-transfer loaded the whole bank
into memory, held a DB connection for the full request, and blocked the
event loop building the ZIP — enough to take down the shared API on a
large bank.

Make export asynchronous, mirroring the already-async import path:
- new document_export operation: submit_export_documents_async enqueues
  it; the worker builds the archive, stores it in file storage, and
  records download_url/storage_key/byte_size in result_metadata
- POST /banks/{id}/document-transfer/export (202 + operation_id)
- the sync GET is removed -> 410, pointing at the async endpoint
- GET /v1/default/files/download/{key} streams the archive; retrieval +
  bank authorization live in MemoryEngine.retrieve_bank_file (IDOR guard)

Harden export_documents: batch the entity/causal attach ANY() queries
instead of passing hundreds of thousands of UUIDs at once, and move ZIP
assembly off the event loop with anyio.to_thread.

Regenerate all SDKs; add blocking export_documents convenience helpers to
the Python + TS wrappers (submit -> poll -> download), fetching the
server-provided download_url to avoid %2F path-encoding. Update the
control-plane proxy to orchestrate the async flow and the docs.

* refactor(transfer): name the export op export_documents; surface it in the CP

- rename the async operation/task type document_export -> export_documents
  (and _handle_document_export -> _handle_export_documents) so it mirrors the
  import_documents operation
- control plane: add export_documents + import_documents to the operations
  type filter and localize both (operationType.exportDocuments/importDocuments
  across all 10 locales) — previously neither appeared in the filter and both
  rendered as the raw task_type string

* feat(transfer): clean up export archives with their operation + add download button

Export archives were stored in file storage but never deleted, so they
outlived their operation: the retention sweep prunes the async_operations
row but left the blob orphaned, and a user delete didn't remove it either.

Tie the archive to its operation record:
- delete_operation now deletes the export archive along with the row
- the retention sweep purges export archives (matching prune's terminal +
  updated_at < cutoff predicate) before pruning the rows

So an export is retained exactly as long as its operation — indefinitely by
default, or until HINDSIGHT_API_OPERATION_RETENTION_DAYS prunes it.

Control plane:
- add a Download button to the export operation's detail dialog (streams the
  archive through a new /api/files/download proxy, SSRF-guarded to the
  file-download path) + localize the label across all 10 locales

* chore(docs-skill): regenerate references for export retention note

* chore(cli): skip new export/download ops in CLI OpenAPI coverage

export_documents_sync_removed (the 410 stub) and download_file are
served via the API/control plane, not the end-user Rust CLI.

* fix(transfer): register export_documents slot config + fix cleanup-sweep tests

- add export_documents to WORKER_SLOT_TYPE_DEFAULTS (every operation_type
  used in memory_engine must be listed there — enforced by test_worker)
- stub engine.purge_expired_export_archives in the operation-cleanup test
  mocks (the sweep now calls it before pruning each schema)

* test(transfer): make export-archive purge test xdist-safe

purge_expired_export_archives is schema-wide, and CI shares the schema
across xdist workers, so a future cutoff purged other concurrent tests'
fresh archives (flaky count + cross-test interference). Backdate this op
and use a past cutoff so it targets only itself; assert purged >= 1.
2026-08-10 17:54:55 +02:00
Nicolò Boschi 35ab0b8162 perf(docs): switch the Docusaurus build to Rspack + SWC (#3357)
The build-docs CI job had crept from ~95s (January) to ~290s, essentially
all of it webpack: the Server bundle took 1.82m and the Client 2.72m on a
4-vCPU runner, with Babel transpiling ~385 routes and no cache surviving
between runs.

Enable Docusaurus Faster, opting in one flag at a time. The blanket
`experimental_faster: true` preset does not work on this site — both the
SWC JS minifier and the SSG worker threads crash rendering /api-reference
with "ReferenceError: Prism is not defined", because Redoc expects a
`Prism` global neither provides. Leaving those two off and taking the
Rspack bundler, SWC loader, LightningCSS and the MDX cross-compiler cache
keeps the build green.

Measured cold builds (14-core machine, cache cleared each time):

  webpack + Babel + Terser (before)  171s
  SWC loader + LightningCSS only     132s
  Rspack + SWC loader + Terser        56s

Output is unchanged: both bundlers emit the same 871 HTML pages, the same
330M build directory and a byte-identical search index.

No CI cache step accompanies this — Rspack's persistent cache only buys
another ~13s (43s vs 56s) and is not worth a 672MB entry against the
repository's cache budget.
2026-08-10 17:52:51 +02:00
Nicolò Boschi 288a9b7fc6 fix(retain): cut oversized sub-batches on native chunk boundaries (#3282) (#3351)
The sub-batch splitter invented its own boundaries — it sliced an oversized
item at `tokens_per_batch * 3` chars, a chars-per-token fudge unrelated to the
chunk boundaries the rest of the retain path works in. Everything downstream
reuses stored work by chunk content hash: delta retain, the streaming recovery
pass, and chunk_index bookkeeping. A slice that happened to line up with native
chunks reused them; one that cut mid-chunk matched nothing, so a replacement
with a small edit plus a tail re-extracted the whole unchanged history — which
is why the bug only appears when `3 * retain_batch_tokens < retain_chunk_size`.

Slice on the bank's own `chunk_text(chunk_size, structured_chunk_size)`
boundaries instead, packing whole chunks up to the token budget, and verify
each slice re-chunks back to exactly the chunks it holds (merged JSON array,
then "\n\n", then "\n"; falling back to one sub-batch per chunk, which
chunk_text's idempotency guarantees — #2301). That makes one invariant hold by
construction rather than by luck:

    the chunks stored for a document depend only on its body,
    never on how transport split it.

Deliberate consequence: a slice honours `retain_batch_tokens` only down to one
native chunk — below that, `retain_chunk_size` is the real bound. Cutting finer
is the defect, not the budget.

Two follow-ons fall out of the same invariant:

* The split reports `chunk_counts`, so the caller stops re-deriving them just
  before handing each sub-batch over — a workaround that existed only because
  the orchestrator pops `content` while streaming (#1888).
* `document_body_override` is Memory Defense screened once, by the engine that
  produces it, instead of by every slice that carries it. A 42 KB body split
  into 26 sub-batches was rescanned 26 times.

Tests: regression coverage for the oversized replacement and for single
screening, unit tests pinning the alignment invariant across prose, JSON
conversation and JSONL payloads, and one covering the unjoinable-run fallback.
2026-08-10 17:40:47 +02:00
JoshFunnell e62015cbf8 feat(llm): restore server-side prompt caching on load-balanced OpenAI-compatible backends (#3271)
Server-side prompt caches are per backend server, so a load balancer scatters
the calls of one multi-call operation (reflect, mental-model refresh,
consolidation) across replicas and the shared prefix almost never hits. This
adds each vendor's documented affinity mechanism to the OpenAI-compatible
provider family: xAI's `x-grok-conv-id` header and OpenAI's
`prompt_cache_key` field, keyed on the operation's trace id.

Measured independently against a live xAI backend with an ~11.9k-token shared
prefix: 29% of it cached without the header vs 99% with it, with a
rotating-id control ruling out header presence as the cause.

Defaults to `auto`, which is an allowlist rather than a best-effort probe:
only x.ai / grok.com (header) and native OpenAI / openai.com / Azure OpenAI
(field) receive anything, and every other backend -- vLLM, ollama, groq,
deepseek, openrouter, lmstudio, custom proxies -- gets byte-identical requests
to before. Set `HINDSIGHT_API_LLM_CACHE_AFFINITY=none` to disable.

Also wires the existing `default_headers` setting into the OpenAI-compatible,
Fireworks and Nous providers, where it was previously accepted and silently
dropped, and folds the duplicate affinity derivation added by #3272 into the
shared module so the two lanes cannot drift.

Full CI via workflow_dispatch on the rebased head (fork PRs get no secrets and
skip test-api): 103 jobs green, all three test-api shards included. The final
rebase changed documentation context only -- no Python differs from the tested
tree (verified with git range-diff).
2026-08-10 17:23:46 +02:00
Vitor Cepeda LopesandTheAngryPit d270b124a9 fix(coding-agents): report and attribute the configured MCP harness (#3342)
The MCP server resolved its harness (HINDSIGHT_MCP_HARNESS, defaulting to
claude-code) for bank resolution but never passed it to buildKnowledgeTools, so
the tools it builds had no idea which agent they were serving.

Two things follow from passing it:

- hindsight_diagnose reports the actual harness instead of 'unknown'.
- hindsight_ingest_document now stamps the harness:<id> tag and metadata.harness.
  Documents ingested through the MCP tool were previously unattributed, and the
  documents list resolves a document's agent logo and filter from exactly those
  fields.

cfg.harness is the right source: loadConfig back-fills the asking harness onto an
unset field (#3247), so it is the launching harness rather than resolveConfig's
'opencode' default.

Co-authored-by: TheAngryPit
2026-08-10 17:19:50 +02:00
Nicolò Boschi 056982b4a2 fix(coding-agents): seed bank missions once, then leave them to the user (#3352)
Closes #2492.

configureBank POSTed the full CODING_BANK_TEMPLATE — reflect/retain/observations
missions included — to /banks/{id}/import on every run, and the server's import
calls update_bank_config unconditionally for whatever the manifest carries. The
seed engine runs on every session start, so a user who rewrote a mission in the
control plane had the plugin's default stamped back over it on the next session.
Same regression #1270 fixed for OpenClaw, arriving here by the same route: the
template was carried over without the guard.

A bank is now seeded once. Before importing, the client reads the bank-scoped
OVERRIDES (not the resolved config, so inherited global defaults don't read as
"already set"); if any mission is set there — ours from an earlier pass or the
user's own edit — it imports CODING_BANK_STRUCTURE instead, which omits the
missions. Omitted fields are untouched server-side: get_config_updates keeps only
non-None values.

The retain strategies and entity labels are still re-applied every time. They are
not preferences: this plugin writes documents under git / gitlog / conversation /
document, a bank missing one would reject the write, and a newer plugin adding a
strategy needs it to reach existing banks.

Two cases deliberately still seed: `configureBank({reset: true})`, because the
bank was just deleted, and a deployment with the bank-config API switched off —
without that API a user cannot set per-bank missions at all, so there is no edit
to protect.
2026-08-10 17:15:12 +02:00
Nicolò Boschi a22667dc06 feat(coding-agents): retainTags / retainMetadata, with HINDSIGHT_RETAIN_TAGS (#3269, #2896) (#3346)
* feat(coding-agents): retainTags / retainMetadata with template placeholders

Closes #3269.

Every conversation retain carries `source:chat` and `harness:<id>` — what wrote
the memory, but nothing about where it came from. That is fine while each repo
has its own bank, since the bank is the answer. It stops being fine on a
deliberately shared bank, the setup in the issue: one bank holding cross-project
knowledge so facts recall everywhere, where a retained fact then carries no
record of the repository it came out of.

Both settings take `{placeholder}` templates resolved per retain, against the
vocabulary the dynamic bank id already uses plus what only a retain knows:

  {gitProject} {project} {harness} {bankId} {sessionId} {timestamp}
  {channel} {user}

  { "retainTags": ["project:{gitProject}"], "retainMetadata": {"repo": "{gitProject}"} }

{gitProject} is worktree-aware here too, so linked worktrees of one repo stamp a
single name rather than project:app and project:app-wt2.

The substitution itself moves to core/template.ts, shared with bank.ts rather
than duplicated — each call site keeps its own resolver map, because the valid
placeholders genuinely differ (a bank id cannot reference {bankId}).

Two things are deliberately not user-controllable. Built-in metadata is written
last and wins, and retainTags entries in the `source:`/`harness:` namespaces are
dropped with a warning: the documents list filters on those and resolves each
document's agent logo from them, so a template that could forge them would break
attribution for everyone reading the list.

Unconfigured, this adds nothing to a retain.

* docs(coding-agents): document retainTags/retainMetadata in the README, not the generated page

The docs page is generated from the integration's README by
hindsight-docs/scripts/sync-coding-agents-doc.mjs, and build-docs runs it with
--check. The first pass edited the generated page, so the build failed with
"docs page is out of date with the README".

Same content, moved to the source and re-synced (README, generated page and the
docs skill mirror). The row's cross-reference is plain text rather than an anchor
link because the generator flattens links.

* feat(coding-agents): HINDSIGHT_RETAIN_TAGS env override

Closes #2896.

The old Claude Code plugin had HINDSIGHT_RECALL_TAGS but no retain counterpart,
so per-project retain tagging could only be configured globally in the file. Now
that retainTags exists here, it joins the env surface on the same convention:
HINDSIGHT_ + the field in SCREAMING_SNAKE, still a FALLBACK the file wins over.

It is a list rather than a scalar, so a new ENV_LISTS branch splits on commas and
trims — blank entries dropped, so a trailing comma or "a,,b" is a typo rather
than an empty tag reaching the API.

  HINDSIGHT_RETAIN_TAGS="project:{gitProject},env:work"

retainMetadata deliberately gets no env form: it is map-valued, and per-key
branching doesn't survive flattening into one variable — the same rule already
applied to mapPathToBank, harnesses and banks.

Also corrects this file's header, which still claimed the plugin reads no
environment variables at all — untrue since ENV_KEYS was added.
2026-08-10 17:14:30 +02:00
Nicolò Boschi 20bd4f3618 fix(ci): compare OpenAPI against the merge-base; build benchmark role configs whole (#3349)
* fix(ci): compare OpenAPI against the merge-base; build benchmark role configs whole

Two unrelated CI failures, both of which fail without anything being wrong
with the code under test.

OpenAPI compatibility diffed the branch's spec against the LIVE tip of the
base branch, so every endpoint main gained after a branch was cut is
reported as "Endpoint removed (breaks old clients)" by that branch. Three
open PRs failed this way today on /health/live and /health/ready (added by
#3329), none of which touch the spec at all; the only cure was an unrelated
rebase. Compare against `git merge-base origin/$BASE_BRANCH HEAD` instead,
which asks the question the check means to ask: did *this branch* remove
something. Genuine removals still fail — verified both directions against
the real specs.

The scheduled LoComo benchmark has failed every night since at least Aug 8,
before its first question: `LoComoAnswerGenerator()` raised
"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID is required". The workflow does export
that variable — but each benchmark role built its LLMConfig from exactly four
env vars (provider, api_key, base_url, model), and LLMConfig deliberately does
not read the environment for provider-specific settings (from_env is where the
API resolves them). Every Vertex AI value was therefore dropped. The same
four-var construction was copy-pasted at three sites — locomo, longmemeval and
the shared judge — so fixing only the crashing one would have moved the failure
down a line. Replace all three with a shared builder that carries the Vertex AI
project/region/service-account through.

hindsight-dev/tests had no CI job, which is why a plain construction bug was
left for a nightly benchmark to find hours later. Add one, so those tests
(and the new regression test) actually run on PRs.

* fix(ci): make the benchmark role-config tests hermetic

They passed locally off the developer's HINDSIGHT_API_LLM_API_KEY and failed
in the new test-dev job, where no key exists, with "API key is required for
openai" — the tests were reading ambient environment instead of declaring
what they need. Clear every HINDSIGHT_API_*LLM* var before each test and set
the ones under test explicitly.
2026-08-10 17:11:06 +02:00
Nicolò Boschi 531b8bb253 fix(gemini): fail fast on deterministic 400 INVALID_ARGUMENT (#3256) (#3347)
A bank-deterministic 400 INVALID_ARGUMENT on consolidation+structured was
being retried through the full LLM retry budget (4 attempts) and the batch
retry ladder above it — 12+ identical rejected calls per consolidation cycle,
recurring every cycle. HTTP 400 is deterministic; retrying cannot recover it.

- Retry classification: 400 now fails fast in both call() and call_with_tools().
  The recoverable cache-400 one-shot is reordered above the fail-fast so it is
  not mistaken for a hard rejection; only 429/5xx still consume the retry budget.
- Diagnosability: dump_request_on_4xx() gains a force flag. A deterministic 400
  now always logs its content-free structural profile (request config + per-part
  sizes) on first occurrence, even with HINDSIGHT_API_LLM_DEBUG_DUMP_4XX off.
  Message previews stay gated behind the opt-in flag, so the forced dump never
  spills user content.

Tests: test_gemini_400_fail_fast.py + force cases in test_llm_4xx_dump.py.
2026-08-10 16:58:48 +02:00
Nicolò Boschi 44b597c484 fix(engine): normalize whitespace in candidate entity names at intake (#3275) (#3338)
Extraction can hand back entity names carrying embedded newlines/tabs, which
are then stored verbatim as entities.canonical_name and shear every
line-oriented consumer (psql -A output, log lines, exports).

Collapse whitespace runs to a single space and strip the ends at
_prepare_entities_for_resolution -- the single choke point both entity
resolution entry paths funnel through, and before the flat list /
entity_to_unit mapping is derived, so the resolver's positional invariant is
untouched. Case is left alone: the registry already matches on
LOWER(canonical_name).

Two consequences handled at the same spot:
- a candidate that is empty after normalization is dropped instead of being
  created as an entity with a blank canonical_name (the resolver has no guard
  of its own);
- candidates that normalization makes identical are deduplicated per fact, so
  the same entity is not resolved twice and its mention_count bumped twice
  (the upstream dedup in entity_processing runs on the raw text).

Existing rows are not migrated: renormalizing a stored name can collide with
the (bank_id, LOWER(canonical_name)) uniqueness, so cleaning them up is a
merge, not an UPDATE.
2026-08-10 16:41:52 +02:00
JiehoonKwakandNicolò Boschi 81b58934d0 fix(search): use PGroonga for Knowledge Pages (#3335)
* fix(search): use PGroonga for Knowledge Pages

Route Knowledge Page lexical search through the PGroonga expression index instead of applying native tsvector functions to PGroonga deployments. Reconcile the renamed mental_models table and preserve its generated tsvector plus GIN index as a rollback projection while adding the canonical PGroonga index.\n\nThe populated-table exception is deliberately narrow: memory_units backend switches and unknown mental-model index shapes remain fail-closed. Empty native reconciliation restores the generated mental-model projection.\n\nContext:\n- #3318 fixed backend dispatch but intentionally treated PGroonga as native because reconciliation still targeted reflections.\n- Existing PGroonga installs therefore retained a tsvector mental_models column.\n- Keeping that projection avoids a rolling-deploy window where old instances or a rollback build would fail after the new index is installed.\n- Query text is escaped with pgroonga_query_escape and the indexed expression is repeated exactly for planner matching.

* fix(search): convert mental_models to pgroonga instead of a hybrid shape

Reconciling to pgroonga now does the same clean conversion for
mental_models as for every other table (drop the derived tsvector, add
the dummy TEXT search_vector, build the expression index) instead of
keeping the native projection alongside it under a renamed GIN index.
The transition is safe on a populated table because pgroonga indexes
name + content directly, so the replacement column has nothing to
backfill; transitions that do need a per-row value (vchord's
bm25vector, native's tsvector, anything touching memory_units) stay
fail-closed.

Trade-off: rolling back to a build without this change leaves pgroonga
knowledge search broken, since the old read arm queries
mm.search_vector as a tsvector.

Also in this pass:

- Make every reconciliation statement re-executable (IF [NOT] EXISTS).
  Replicas boot concurrently during a rolling restart and each runs this
  reconciliation; on a populated database the loser of the race would
  otherwise crash on DDL the winner had already committed.
- Share the mental_models document expression between the index DDL and
  knowledge_bm25_arm — an expression index is only selectable when the
  query repeats its expression verbatim, so the two must not drift.
- Match the learnings/pinned_reflections migration's generated-column
  expression exactly.
- Tiebreak the pgroonga ordering: pgroonga_score() silently reads 0 on
  any plan that did not use the pgroonga index.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-10 16:37:27 +02:00
Nicolò Boschi 55d40d944c docs(config): name LM Studio/Ollama/Volcano for LLM_STRICT_SCHEMA (#3348)
The HINDSIGHT_API_LLM_STRICT_SCHEMA description listed the OpenAI-compatible
backends it applies to but omitted LM Studio, Ollama, and Volcano — which are
exactly the providers whose soft path skips json_object grammar, so their small
models emit unconstrained output. Enabling the flag is the documented fix for a
JSONDecodeError during retain on those backends (see #3262).

Regenerated the skills/hindsight-docs mirror to match.
2026-08-10 16:37:02 +02:00
BenandClaude Opus 4.8 6cb39aef8f blog: guest post — writenode, continuity over retrieval (Josh Groves) (#3243)
* blog: guest post — writenode, continuity over retrieval (by Josh Groves)

Community guest post by Josh Groves (@Xp3rtMag1c1an), maker of writenode, on
building an AI note-taking Chrome extension on Hindsight: per-user memory
banks, a mode classifier, Node Gravity, and the SOURCE NODE chat. Adds the
post, four product screenshots, a co-brand cover, and an authors.yml entry.

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

* blog(writenode): add benfrank241 as co-author

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

* blog(writenode): update date to 2026-08-10

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-10 10:07:08 -04:00
Nicolò Boschi d506641dc3 fix(transfer): reject a wrong import zip with a 400 that names the fix (#3339)
Selecting a zip in Documents > Actions > Import from zip and pressing the
button dimmed it briefly and then did nothing (#3327). The zip was a zip of
the reporter's own documents, which is rejected — correctly and quickly, so
the button only dimmed for a moment — but the rejection never reached the
screen. #3333 fixed that half: the control plane now toasts the failures of
the direct-fetch helpers that bypass the shared error interceptor.

Two things it did not cover remain.

A file that isn't a readable zip escaped parse_archive as
zipfile.BadZipFile and surfaced as an opaque 500. That is a wrong upload,
not a server fault, so it now raises ValueError like every other archive
problem and the API maps it to a 400 with the reason. The check moves into
a shared _open_archive helper so parse_bank_archive gets it too.

And the rejection now names the fix rather than only the missing file:
"Import from zip" reads like a bulk upload of ordinary files, so the
message says the endpoint only accepts an archive produced by export and
points at file upload / retain for PDFs and text. The import dialog gained
the same hint under the file picker, pointing at Add Document > Upload
Files.
2026-08-10 16:00:53 +02:00
Parafee41 969cb3e5dd fix(reflect): provide the current date and time for temporal reasoning (#3287)
Add the current UTC date and time to the reflect retrieval and final-synthesis
prompts so the agent has a reference point for time-relative questions
(recently elapsed plans were staying classified as upcoming).

Time is at minute precision and placed after the static instruction block —
right before the bank-specific/custom data — so the large static prompt stays
a cacheable prefix and only the volatile timestamp falls outside the cache.

Closes #3279.
2026-08-10 15:44:09 +02:00
JoshFunnell 82852c5225 feat(llm): xai-oauth provider — LLM lanes on a SuperGrok subscription (#3272)
Adds the `xai-oauth` provider: serves Hindsight's LLM lanes from a flat-rate
SuperGrok subscription via an RFC 8628 device-code grant against auth.x.ai, with
proactive/reactive refresh over a shared 0600 on-disk store. No API key. For
API-key access to the same endpoint, `provider: openai` with an api.x.ai base
URL still applies.

Reviewed and tested live against a real xAI subscription (grok-4.5): device-code
login, plain/structured/tool-calling completions, and a real token refresh.
Six follow-up fixes landed on top of the contribution, each with a RED-proven
regression test — see 5e04ab0be for the detail.

Full CI (workflow_dispatch on the rebased ref, since fork PRs get no secrets and
skip test-api): 103 jobs green, all three test-api shards included.
2026-08-10 15:40:30 +02:00
Nicolò Boschi 3a4b0214f9 fix(ollama): make native think configurable via extra_body (#3344)
The native structured-output path hardcoded think=False, so gpt-oss models
failed fact extraction (they require a thinking level). Rather than a
model-name heuristic, merge the configured extra_body into the native
/api/chat payload: top-level native fields (think, keep_alive, ...) pass
through, and an "options" sub-dict merges into Ollama's generation options.

Set HINDSIGHT_API_LLM_EXTRA_BODY='{"think": "low"}' for gpt-oss.

Fixes #3246
2026-08-10 15:35:48 +02:00
Nicolò Boschi 1ffe875983 release(coding-agents): v0.1.2 2026-08-10 15:22:45 +02:00
Nicolò Boschi aa0aa7649b fix(coding-agents): bound transcript reads so an oversized session still retains (#3345)
Closes #3292.

Every live-transcript reader did `readFileSync(path, "utf8").split("\n")`. Past
V8's maximum string length (~537M chars) that throws ERR_STRING_TOO_LONG before a
single record is parsed; the readers catch it as "unreadable file" and return no
turns, so the Stop hook exits successfully having retained nothing. An agent that
had been running for weeks just stops updating memory, with no error anywhere.

Reproduced on a 575MB Codex rollout: readFileSync throws ERR_STRING_TOO_LONG,
readCodexTranscript returns []. With this change the same file yields 8,162 turns
ending in the most recent exchange, in 51MB of heap.

core/jsonl.ts streams the file a chunk at a time through a StringDecoder (so a
multi-byte character split across chunks is reassembled) and reads only the last
MAX_TRANSCRIPT_BYTES. Truncating the HEAD is what makes a cap safe here: the
recent exchange is the part worth retaining, and the incremental write-back only
sends turns after its cursor anyway. Landing mid-record drops that fragment
rather than emitting half a line; starting one byte early keeps a record whose
boundary the cut happened to land on. A truncated read is logged, never silent —
which was the actual complaint.

Applied to all six readers that shared the pattern (claude-code, codex,
antigravity-cli, copilot-cli, cursor-cli, grok-build), not just the one filed:
same bug, same line, and a large Claude Code transcript fails identically.
2026-08-10 15:19:30 +02:00
Nicolò Boschi 2b0ed82f30 fix(coding-agents,ci): drop the periodic re-sync; stop running doc examples for integrations (#3341)
Two things, both about cost.

The periodic full re-sync (a replace every 20 appends) defeated the point of
appending: on a long session it re-uploads the entire document on a fixed cadence,
which is the expense this whole path exists to avoid. Removed.

What it was insuring against still stands, and is now accepted: retains are async,
so a write can be acknowledged and then fail server-side, and a resubmitted
operation_id replays the original operation whatever its status — those turns are
not re-sent. The other replace triggers (no cursor, fingerprint drift, dirty, bank
change) are unaffected, so a write we can SEE fail still self-heals; only a
silent server-side failure after acknowledgement is uncovered.

Second: test-doc-examples gated on a `docs` filter that matched
'hindsight-integrations/**' — added so an integration rename would reach the docs
build's integrations check, except build-docs has no `if:` and runs on every PR
regardless. So the only consumer of that breadth was the doc-examples matrix,
which runs every sample against a live LLM-backed server: four provider-credentialed
jobs on every integration PR (and every prose-only docs PR), none of which those
files can affect.

It now gates on a `doc-examples` filter covering the runnable samples themselves
(hindsight-docs/examples/**) and their runner. `docs` had no other consumer, so it
is removed rather than left as config nothing reads.
2026-08-10 14:51:33 +02:00
Nicolò Boschi 00bad17110 fix(api): add a DB-free liveness probe so a slow database stops restarting pods (#3337)
Adds /health/live (no DB access) and /health/ready alongside the existing /health, on the API server and the worker. Helm liveness probes now use /health/live; readiness stays on /health. Worker liveness reports seconds_since_last_poll for alerting without gating on it.

Fixes #3329
2026-08-10 14:49:11 +02:00
Sanderhoff-alt 0e652006d1 fix(control-plane): surface direct request failures (#3333)
Show errors from document export, transfer import, and file uploads
that bypass the shared API error interceptor.

Format API errors safely so structured validation details remain
readable.
2026-08-10 14:41:56 +02:00
Alan5168 862a77c1a8 fix(api): validate UUID on get_entity and get_observation_history (#3260)
get_entity and get_observation_history passed entity_id / memory_id straight
to uuid.UUID() without a try/except. A malformed id (typo, copy-paste error)
raised a bare ValueError that the HTTP handler mapped to 500 instead of 400.

get_memory_unit and update_memory_unit already validate their id this way
(#906, #3062); these two endpoints were missed.

Wraps both in the same try/except and adds a ValueError -> 400 branch to the
two HTTP handlers (api_get_entity, api_get_observation_history). Adds stub-
engine regression tests mirroring test_delete_memory_units_validation.py.
2026-08-10 14:34:07 +02:00
Nicolò Boschi fd294227c6 feat(transfer): carry Knowledge Pages tree and regenerate mental-model search state on import (#3308, #3323) (#3330)
Whole-bank export/import previously dropped the Knowledge Pages tree
(knowledge_pages was in _SKIP_TABLES because its self-referential parent_id
FK needs a topological restore) and restored mental models without an
embedding or lexical search state — leaving imported knowledge pages
disconnected and unsearchable, on every text-search backend.

Export:
- Add a typed TransferKnowledgePage model (no raw dicts across phases) and
  carry the folder/page tree in knowledge_pages.json, parent-first, preserving
  id, parent_id, mental_model_id, managed, sort_order, name and timestamps.
- Remove knowledge_pages from _SKIP_TABLES; classify it under a new
  KNOWLEDGE_TABLES bucket (coverage guard updated).

Import:
- Regenerate each restored mental model's embedding with the TARGET model
  (same "{name} {content}" text create_mental_model embeds), off-connection so
  no DB conn is held across the embedding call.
- Rebuild backend-specific lexical state via the shared pg_search_vector_expr
  (vchord's bm25vector column; native's is GENERATED and repopulates on insert;
  pg_search/pg_textsearch/pgroonga index base columns).
- Restore the tree after its backing mental models exist and parents-first
  (topological order tolerant of cycles/dangling parents), ON CONFLICT DO NOTHING.

Tests: whole-bank roundtrip asserts the nested tree restores exactly (ids,
parents, mm refs, managed) and pages are searchable after import with no NULL
mental-model embeddings; plus non-DB unit tests for the topological ordering.
2026-08-10 14:27:28 +02:00
Parafee41 36eb64dfba fix metapackage embed version coupling (#3261) 2026-08-10 14:19:25 +02:00
Nicolò Boschi 815f5aaa36 fix(coding-agents): write back only the new turns (append + idempotent retain) (#3336)
* feat(coding-agents): write back only the new turns (append + idempotent retain)

The live write-back re-uploaded the WHOLE conversation on every Stop, and every
N turns under the persistent-plugin runtime. A long session therefore re-sent its
entire transcript each time, which is what turns a large session into an
unretainable one rather than merely a slow one.

Retains now carry a per-session cursor. A session that has already been written
appends only the turns added since the last successful write, using the server's
`update_mode: "append"` (supported since #932); the server concatenates them onto
the stored document with "\n", which is exactly why the transcript is JSONL.

Append is only correct while our view of the document matches the server's, so
every uncertain case falls back to the full REPLACE this always did - no cursor,
a transcript that was rewritten rather than extended (compaction, a truncated
rollout), or a previous write whose outcome is unknown. Replace is idempotent by
construction and so is always the safe recovery.

Two supporting changes:

- Conversation retains carry a deterministic v5 `operation_id`, so a resubmitted
  write is collapsed into the original operation instead of being applied twice.
  That is what makes append safe: the client aborts at 15s, and a server that
  committed the write anyway would otherwise get the same turns again. The field
  landed in v0.8.6 (#2937/#2947) and is silently IGNORED by anything older, so
  the append path is gated on a cached GET /version probe and older servers keep
  replacing.
- `RetainOpts.async` is gone. Nothing ever passed `false`; retains are always
  async, and nothing in this plugin can afford to block a hook on extraction.

Backfill, git, knowledge and survey retains are untouched: they keep replacing,
and deliberately do not take a deterministic operation id, so re-retaining
identical content after a document is deleted still restores it.

* fix(coding-agents): serialise a session's write-backs so appends cannot overlap

Found reviewing the append cursor: the runtime fires retains without awaiting
them, and reading the cursor was not atomic with claiming it — the capability
probe awaits in between. Two overlapping write-backs therefore both planned an
append from the SAME position and submitted overlapping slices, duplicating turns
inside the document:

  replace(REF-ID + turns 0-4), append(turns 5-7), append(turns 5-8)

Serialising only the claim would not have fixed it either: that leaves an append
racing a replace on the wire, where the order they land in decides the outcome.
The whole read-plan-send-confirm cycle is now chained per session, so each
write-back plans against the previous one's CONFIRMED cursor.

The runtime's idle test now waits a tick before asserting on the fire-and-forget
retain, as its sibling assertions already did — one extra microtask hop.

* fix(coding-agents): key the write-back cursor to the bank it wrote to

The cursor is keyed by (harness, session id), but the bank is re-derived from
each hook event's cwd — so a session that moves between repos (#3133) keeps its
id and changes bank. The new bank holds no document for that session, and the
cursor still claimed a position in it:

  bank repo-a: replace(REF-ID + turns 0-4)
  bank repo-b: append(turns 5-7)      <- turns 0-4 never existed here

The cursor now records the bank it wrote to, and a mismatch replaces. Same
reasoning as the fingerprint and dirty checks: anything that makes our view of
the document unreliable falls back to the full write.

Also covers a config change (mapPathToBank, an explicit bankId) that re-points a
live session at a different bank.

* fix(coding-agents): re-sync the whole document every 20 appends

Review follow-up. Retains are async: the server acknowledges the submission and
extracts later, so a write can be confirmed to us and still fail afterwards — and
_resolve_retain_replay returns a prior operation whatever its status, so
resubmitting the same payload will not redo it. Replacing everything used to be
self-healing precisely because each write re-sent the whole document; appending
gives that up, and a single lost write would otherwise cost the rest of the
session.

A full write every MAX_APPENDS_BEFORE_RESYNC appends bounds that to the turns
since the last re-sync. A replace of any kind resets the count.

Also from the review:
- drop a session's chain entry once it settles, so a host that outlives many
  sessions (opencode runs for days) does not keep one resolved promise per
  session id forever
- pin the version test to MIN_IDEMPOTENT_RETAIN_VERSION rather than repeating
  the literal, which also gives the exported constant a consumer
2026-08-10 14:16:52 +02:00
Nicolò Boschi f8e588c042 fix(api): gate knowledge-base routes through the operation validator (#3312) (#3331)
Knowledge-base routes reached the engine without invoking
OperationValidatorExtension, so any authenticated tenant could read or
write another bank's knowledge tree — including the mental-model content
that pages render — by knowing its bank_id.

- Add knowledge-base members to BankReadOperation (tree/get-page/search/
  export) and BankWriteOperation (create-folder/create-page/update-page/
  rename/move/delete).
- Gate all nine KB engine methods through _validate_operation before any
  read or write, mirroring the mental-model paths. This also makes the
  KB routes' pre-existing (previously dead) OperationValidationError
  handlers reachable.
- Add a _nested_operation_authorized contextvar so composite methods
  (create_knowledge_page, update_knowledge_page) and the new
  export_knowledge_base engine method fire exactly one validator hook and
  never auto-create a bank on an unauthorized path.
- Move export bundle data-gathering into export_knowledge_base (typed
  KnowledgeBaseExport/KnowledgeBaseExportPage); http.py only renders.

Tests: deny bank_read -> 403 leaking no content on tree/get-page/search/
export; deny bank_write -> 403 leaving the tree unchanged and no bank row;
success paths assert exact hook counts.

Follow-up to #3036 / #2488.
2026-08-10 13:59:42 +02:00
Nicolò Boschi e852acc7c0 feat(reflect): resolve entity names on reflect sub-recalls (#3334)
recall_async only populates each result's entities field when
include_entities=True, and it defaults to False — so reflect's recall and
search_observations tools never surfaced them. Canonical entity names are
semantic signal the surface text may lack ("Bob" in the text vs canonical
"Robert Smith"): they give the agent resolved names to cite and to pivot
follow-up queries on, for the cost of one extra lookup query per recall.

The top-level EntityState dict recall also builds is not serialized into
tool results; only the per-fact names reach the agent.
2026-08-10 13:59:02 +02:00
DragonKidandNicolò Boschi e9a14da690 fix(structured-doc): add TableBlock to fix markdown table rendering (#3289)
* fix(structured-doc): add TableBlock to fix markdown table rendering

StructuredDocument only supported 4 block types (paragraph, bullet_list,
ordered_list, code). When the LLM generated markdown tables, _parse_block
treated the multi-line table as a ParagraphBlock and joined all lines with
" ".join(), collapsing the table into a single line and breaking formatting.

Add a TableBlock type with headers/rows fields, plus parse and render
support. The parser detects table chunks by checking that all lines match
the markdown table row pattern and at least one line is a separator
(|---|---|). The renderer emits standard markdown table syntax with
one row per line.

Also update the delta ops prompt to include the table block shape so
delta refreshes can emit table operations correctly.

* fix(structured-doc): escape pipes in table cells and cover TableBlock with tests

The new TableBlock joined cells with a bare " | ", so a cell whose text
contained a pipe emitted extra columns and re-parsed into a different block
— the render/parse round-trip the structured-delta architecture depends on
was not stable for tables. Escape \\ and | on render, scan escapes on parse.

Also: a table with no headers dropped every row (rendered ""), rows wider
than the header lost cells to GFM, and the no-separator branch of
_parse_table_block was unreachable. Adds the missing unit tests.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-10 13:55:20 +02:00
Nicolò Boschi 3e3e3f372c feat(bank-template): make every bank config field export+importable (#3332)
Seven per-bank configurable fields were not declared on BankTemplateConfig, so
export/import silently dropped them: consolidation_llm_parallelism,
consolidation_max_memories_per_round, enable_auto_consolidation, memory_defense,
recall_chunks_max_tokens, recall_include_chunks, recall_max_tokens. Cloning a
bank produced a clone that looked correctly configured while quietly running on
the server defaults for those seven — and memory_defense being one of them means
a bank's defense policy did not travel with it.

All seven are now part of the template engine, so an exported bank reproduces its
full configuration on import.

The rest of the change is about not needing to notice this again. Adding a
per-bank config field is a multi-step flow, and each step now fails until the
previous one is done:

1. add it to _CONFIGURABLE_FIELDS -> test_every_configurable_field_is_exportable
   fails until it is declared on BankTemplateConfig (both directions: a template
   field that is not configurable fails too, since the engine would reject it);
2. declare it there -> test_sample_values_cover_every_exportable_field fails
   until it has a value in _SAMPLE_VALUES;
3. give it a value -> the existing round-trip test exercises it end to end;
4. touching BankTemplateConfig moves the OpenAPI spec, the generated clients and
   bank-template-schema.json, so verify-generated-files fails until those are
   regenerated.

An intentional exclusion is now a decision to record in the guard with a reason,
not an omission that no one sees.

The docs listed 15 of the 45 fields in a hand-maintained table that was already
stale and would contradict "every field is supported" the moment it drifted
again. It now states the guarantee and points at the generated schema as the
authoritative list, keeping the common fields as examples.

Verified by mutation: adding a configurable field without a template field,
adding a template field that is not configurable, and adding a template field
with no sample value each fail the suite.
2026-08-10 13:49:21 +02:00
JoshFunnellandNicolò Boschi ea0d5ead0a perf(reflect): drop retrieval plumbing from reflect tool results (#3310)
* perf(reflect): drop retrieval plumbing from reflect tool results

Reflect tool results are handed to the model verbatim, so every field in them
is spent context. `search_observations` and `recall` currently serialize the
whole result model, which includes retrieval internals the agent never reads:
per-stage `scores`, ingest `metadata`, extracted `entities`, and the
`chunk_id` / `document_id` plumbing. On real banks that envelope measures
several times the observation text it accompanies.

These are internals rather than evidence, and the loop does not depend on any
of them: the agent cites by `id`, `based_on` persists only
id/text/type/context, and the expand tool takes `memory_ids` and resolves
chunks server-side. Identity, text, dates, tags and `source_fact_ids` are all
kept.

`chunks` in `recall` is deliberately left alone: `ChunkInfo` carries only
chunk_text / chunk_index / truncated, so it holds none of these fields and
trimming it would be a no-op. A test pins that, so if a future field lands
there the decision is revisited rather than quietly going stale.

Scope, stated plainly: this reduces the envelope, it does NOT implement the
accounting change #3122 asks for. The token budget still counts observation
text only, and forced synthesis still drops oversized blocks whole, so the
user-visible failure in that issue -- a confident "no information" answer
carrying hundreds of citations -- can still occur on a large enough result
set. This is a smaller, independent improvement; #3122 should stay open.

Tests pin both directions, since the risk in removing fields is that
something downstream quietly needed one: every trimmed field is gone, and
every field the loop depends on survives.

* keep entities in reflect tool results: canonical names, not plumbing

The entities field carries canonical entity *names* (not ids), which are
semantic signal the surface text may lack ("Bob" in the text vs canonical
"Robert Smith"). Reflect's recalls don't populate it today
(include_entities defaults to False, so _prune_nulls already drops the
None), but trimming it would bake in eating the names if that ever flips
on. Only true plumbing stays trimmed: scores, metadata, chunk_id,
document_id.

---------

Co-authored-by: Nicolò Boschi <[email protected]>
2026-08-10 13:41:47 +02:00
Nicolò Boschi e9d1676ef3 test(bank-template): round-trip every exportable field through export+import (#3324)
Existing coverage left a gap in the middle. test_bank_template_configurable_fields
sets fields one at a time and never exports, so it proves import writes a field,
not that export reads it back. TestExport::test_export_reimport_roundtrip does
export then import, but with a single config field, and asserts only the response
flags (config_applied is True) — never that a value survived.

A field that import accepts but export drops, or that export reshapes into
something import rejects, passes both today.

This sets every field BankTemplateConfig declares on one bank, exports it,
imports the exported manifest into a fresh bank, and asserts the clone's
overrides match the source's. Overrides, not resolved config: the resolved view
would hide a dropped field behind the server default and read as a pass. The
comparison is source-vs-clone rather than against the literal input because some
fields normalize on the way in (entity_labels migrates legacy shapes) — the
property under test is that a clone ends up configured like its source.

BankTemplateConfig.model_fields is the exportable surface (the export endpoint
filters overrides through exactly that set), so a guard test asserts the sample
table matches it. Adding a template field fails that guard until it gets a
value, which stops the round-trip from silently narrowing.

Verified by mutation: dropping a field from the export filter, dropping one from
BankTemplateConfig.get_config_updates(), and adding an unsampled template field
each fail the suite.
2026-08-10 13:05:04 +02:00
Nicolò Boschi 2cf31d7fbe fix(config): validate bank config value types and stop them wedging tasks (#3218) (#3319)
The bank-config API took `dict[str, Any]` updates and never checked the values
against the declared `HindsightConfig` field types, so a client could store a
JSON object in a string-typed field such as `observations_mission`. The write
succeeded; the bank then failed *every* consolidation with "expected string or
bytes-like object, got 'dict'" — `re.sub` inside `escape_for_prompt`, reached
from prompt assembly. Deterministic, so the bank never recovered. A sweep of one
deployment found 7 banks across 7 tenants in this state, spread by a third-party
tool that writes structured JSON into the mission/instruction fields.

- Write side: `validate_bank_config_updates` now type-checks every value against
  the field's dataclass annotation and rejects with a 400 naming the field and
  the expected type. Applies to nested `retain_strategies` overrides too, since
  `apply_strategy` splices those onto the resolved config and they reach the same
  fields. `entity_labels` keeps its wider contract (list or {"attributes": [...]}).
- Read side: banks configured before this landed are tolerated rather than
  wedged. A non-string in a string field is JSON-encoded — the same remediation
  applied in the field, and it preserves intent since the structure still reaches
  the prompt as text. Anything not coercible is dropped with a WARNING so the
  bank falls back to the tenant/global value.
- Observability: task-failure paths log `exc_info` instead of a bare
  `traceback.print_exc()` (the stderr copy carries no task id and rotates away
  first), and messages/stored `error_message` go through `format_task_error`, so
  an exception with an empty `str()` no longer logs
  "Task execution failed: graph_maintenance, error: ".
2026-08-10 13:04:45 +02:00
Nicolò Boschi aefc5e8e7d test(ci): gate every build on hermes-agent@main co-installability (#3265)
Hermes installs `hindsight-all` into its OWN venv via `hermes memory setup` to
run memory in local_embedded mode, and exact-pins every direct dependency
(`==X.Y.Z`) as a deliberate supply-chain policy. Any version range Hindsight
declares that excludes one of their pins therefore makes the two impossible to
co-install for every Hermes user on embedded memory — that is what #3251 hit
with our cryptography/pillow floors.

Hermes main has since bumped to cryptography==48.0.1 / Pillow==12.3.0, which
already matches our floors, so no dependency change is needed and none is made
here. What was missing is the check that keeps it that way: both sides bump on
their own schedule, so the collision recurs silently until something looks for
it. Tracking their main branch surfaces it while it is still cheap to fix on
either side rather than in a released Hermes.

scripts/test-hermes-compat.sh runs five checks of increasing depth: resolution
(both into ONE resolution, so an unsatisfiable pin is a hard error instead of a
silent downgrade), `pip check` plus a dump of the contested versions, `hermes
memory status`, the runtime imports Hermes makes for embedded memory, and a real
embedded daemon boot with bank operations.

Implementation notes:

- Hindsight installs from BUILT WHEELS, not `file://` directories. uv installs a
  workspace member given as a directory such that hindsight_embed.__file__ still
  points into the source tree, and the daemon manager keys dev-mode detection on
  that path — so a directory install launches the API via
  `uv run --project <repo>/hindsight-api-slim`, out of the monorepo's venv and
  .env, bypassing the Hermes venv this script exists to test. Step 5 asserts the
  daemon binary resolves inside the test venv so this cannot regress.
- Hermes is cloned and installed editable: their build backend refuses
  wheel/sdist builds by design, so `git+https://` fails outright.
- Python is pinned to 3.12 rather than .python-version because Hermes caps
  itself at <3.14; the venv must sit inside both projects' windows.
- Hindsight state is isolated by a dedicated `hermes-ci` profile rather than by
  redirecting HOME, which would also hide the uv/HuggingFace caches from the
  runner and re-download the local-ml stack every run.
- Step 5 runs from the work dir, not the repo, so a developer's .env cannot hand
  the daemon credentials a runner does not have.
- MemoryEngine refuses to construct without an LLM key, so the daemon boots on a
  placeholder one; nothing calls the LLM during startup or bank operations.

The job needs no secrets and so runs on fork PRs too. Only the retain/recall
round-trip requires a real LLM key and is skipped without one.

Verified end-to-end locally against hermes-agent main (0.20.0): all five steps
pass, 225 packages consistent, daemon boots from the test venv and stops cleanly.
2026-08-10 13:03:40 +02:00
Nicolò Boschi 18bff79aea fix(api): knowledge-base search 500s on non-native text-search backends (#3268) (#3318)
* fix(api): dispatch knowledge-base search on the text-search backend (#3268)

search_knowledge_pages hard-coded the native tsvector SQL
(ts_rank_cd / @@ over mm.search_vector) in both its RRF BM25 arm and its
embedding-unavailable fallback. But mental_models.search_vector is only a
tsvector under the `native` backend; under pg_search / pg_textsearch /
vchord it is a dummy TEXT (or bm25vector) column, so knowledge-base search
500'd with `function ts_rank_cd(text, tsquery) does not exist` on every
non-native deployment while recall (which already dispatches) kept working.

Add knowledge_bm25_arm() in the PG dialect — the same per-backend dispatch
PostgreSQLDialect.build_bm25_arm already does for memory_units — and route
search_knowledge_pages through it:

- native / pgroonga: generated tsvector (ts_rank_cd / @@). pgroonga's
  mental_models is never reconciled to pgroonga structures, so it keeps the
  migration-time tsvector and the native operators are correct for it.
- pg_search: paradedb.score / @@@ over the (id, name, content) BM25 index.
- pg_textsearch: BM25 distance over the content column.
- vchord: its bm25vector column is never populated on mental-model writes,
  so the BM25 index is empty — degrade to a vector-only search rather than
  emitting SQL that returns nothing (or 500s).

Fix the stale docstring that claimed a tsvector for all backends, and add a
backend-dispatch regression test that pins the SQL each backend emits
(no live extension required, matching test_multilingual_bm25).

* fix(api): make knowledge-base search reuse the recall BM25/vector logic for all backends (#3268)

Follow-up to the first cut, which degraded vchord to a vector-only search
because mental_models.search_vector was never populated. Instead, reuse the
exact per-backend logic the memory-recall path already uses so knowledge
search works identically across native / pgroonga / pg_search / pg_textsearch /
vchord — read AND write.

Write side: mental_models.search_vector is now tokenized on write for vchord
via the shared pg_search_vector_expr helper (the same single source of truth
insert_facts_batch / consolidator use for memory_units), threaded through the
create-pinned INSERT, the update-mental-model UPDATE (re-tokenized only when
name/content change), and clear-mental-model. The helper gains signals_col=None
(two-column tables) and native_inline=False (mental_models' native search_vector
is a GENERATED column that populates itself, so only vchord's plain bm25vector
column needs an explicit value). memory_units keeps its three-column,
native-inline behaviour unchanged.

Read side: knowledge_bm25_arm now emits a real vchord BM25 arm (negated <&>
distance over search_vector, gated > 0) mirroring build_bm25_arm, and no longer
returns None — search_knowledge_pages drops the vector-only/empty-result
branches.

Tests: pin the vchord read arm and the per-backend write tokenization (only
vchord writes; memory_units default expr unchanged).
2026-08-10 12:54:06 +02:00
Parafee41 4c3a0bf295 fix codex extra body forwarding (#3305) 2026-08-10 12:52:37 +02:00
Nicolò Boschi 76a74b3181 fix(worker): serialise graph_maintenance per bank at claim time (#3235)
Every graph_maintenance run is the same bank-wide sweep: the payload carries
only bank_id, run_graph_maintenance_job discards the request context, and the
relink pass drains the whole queue. A second concurrent run for one bank adds
no work — and claim_graph_maintenance_batch locks queue rows FOR UPDATE with no
SKIP LOCKED precisely because it assumes a single runner per bank, so the runs
convoy on each other while each holds a worker slot.

Same guarantee consolidation already gets from its busy-bank exclusion, applied
as a predicate on the existing claim queries rather than a claim phase of its
own. graph_maintenance has no reserved-slot floor and the poller's fairness pass
claims with shared_limit=1, so a trailing phase would drop it below every other
operation type and let a single pending retain starve it; as a predicate it
keeps competing by created_at.

The predicate also takes at most one same-bank row per batch. Excluding busy
banks alone does not cover that: with several pending rows and nothing yet
processing, one batch claims them all. Several pending rows per bank are
reachable through the recovery paths — recover_own_tasks resets all of a
worker's processing rows at once, plus _schedule_retry / _defer_operation /
admin recover.

Fixes #3230
2026-08-10 12:27:27 +02:00
Parafee41 6d61772190 fix root worktree project resolution (#3286) 2026-08-10 12:25:19 +02:00
Nicolò Boschi d733772e39 fix(engine): stop concurrent bank deletes from deadlocking on vector-index DDL (#3245)
DROP INDEX CONCURRENTLY on the shared memory_units table deadlocks with
other sessions' index DDL by design; CI's end-of-run teardown storm
outlasted the drop path's ~2.4s retry budget. Serialize per-table index
DDL in-process on PostgreSQLOps and give the drop path a ~30s jittered
retry budget for the cross-process residue.
2026-08-10 12:08:56 +02:00
JiehoonKwak 5f1237279b fix(docker): repair PGroonga Compose image (#3316)
The PGroonga example referenced groonga/pgroonga:latest-debian-pg17, which has no registry manifest, so the documented Compose stack could not build.\n\nPin the current PGroonga 4.0.8 PostgreSQL 17 image and install pgvector 0.8.6 from the PGDG repository already configured by that base image. This removes the source clone and build toolchain while keeping the example on PostgreSQL 17 for parity with neighboring recipes.\n\nContext:\n- Fixes #3311.\n- Verified on arm64 with a disposable PostgreSQL instance.\n- CREATE EXTENSION vector and pgroonga both succeed.\n- Mixed Korean/English PGroonga search and pgvector distance queries pass.\n- PostgreSQL 18 deployment work remains a separate operational concern.
2026-08-10 12:05:51 +02:00
github-actions[bot] 3a48b6e5bb chore: update star history 2026-08-10 03:58:09 +00:00
github-actions[bot] f1c825d884 chore: update star history 2026-08-09 03:53:28 +00:00
Nicolò Boschi 4b2041eb3d release(coding-agents): v0.1.1 2026-08-08 12:57:49 +02:00
Nicolò Boschi cbbc864694 fix(coding-agents): seed the actual harness, not the "opencode" default (#3247) (#3266)
The background seed engine (deepen.js) resolves {harness} from cfg.harness,
which falls back to a hardcoded "opencode". buildSessionStartContext fired the
seed via startSeed(cwd, { limit }) without the harness — so every non-opencode
session's codebase survey and git history were misfiled into an
`opencode::<project>` bank that no session ever reads, while the session hooks
correctly wrote to `<harness>::<project>`.

- session-start.ts: forward the asking harness to startSeed, mirroring the
  survey spawn right beside it.
- config.ts: resolve cfg.harness to the harness that called loadConfig when the
  config file sets none, instead of the silent "opencode" default — this also
  fixes the same latent misfiling for kilo and opencode-fork harnesses.

Also (#3248): add a supersession clause to OBSERVATIONS_MISSION so a revised
convention updates its existing observation instead of accumulating a
contradictory sibling, matching the language already used in the conversation
and reflect missions.

Verified end-to-end with the built artifacts: the real claude-sessionstart-hook
now spawns `deepen.js ... --harness claude-code`, which resolves
`bank=claude-code::<project>`.
2026-08-08 12:55:56 +02:00
github-actions[bot] 5b2f5d82c2 chore: update star history 2026-08-08 03:48:27 +00:00
Nicolò Boschi 13d9f2df95 docs(blog): fix broken /docs/developer links in the 0.9.0 release post 2026-08-07 18:56:39 +02:00
Nicolò Boschi eb47374fa3 docs: changelog and blog posts for v0.9.0 (#3189)
* docs: draft blog posts for v0.9.0 (release + coding-agents)

* docs: 0.9.0 changelog + consolidate coding-agents launch post

* docs: update launch post to the shipped coding-agents harness list

* docs(blog): add per-agent wall time to the 0.9.0 benchmark table

The table reported corrections and cost but not how long a task took, which is
the number a reader feels. Computed from the same runs as the other columns —
outputs/sdebench/{hindsight,vanilla}-{claude,codex,opencode}-{1,2,3} on the
benchmark repo's main, meta.wall_s averaged per task across the three runs of
each arm:

  Claude Code   84.7s -> 75.1s  (-11%)
  opencode     174.2s -> 163.3s (-6%)
  Codex CLI     53.6s -> 48.6s  (-9%)

Verified against the published columns before trusting the source: the same
files reproduce corrections 0.85/0.36, 1.20/0.80, 1.34/0.47 and costs -24%,
-13%, -52% exactly.

The prose claim is deliberately the weaker one — every memory run beat its
agent's vanilla AVERAGE, which holds for all nine. 'Faster than every vanilla
run' does not: opencode's slowest memory run (171s) is slower than its fastest
vanilla run (163s), and Codex ties at 51s.

* docs: regen 0.9.0 changelog and expand release blog for new features

* docs: refresh 0.9.0 changelog (99 commits)

* docs: date the 0.9.0 posts (launch 08-06, release 08-07) and unset draft

* docs(blog): embed 0.9.0 launch video, add coding-agent logos cover, cross-link posts

* docs(blog): drop cover image from the 0.9.0 release post (keep it on the launch post)

* docs(blog): fix coding-agents install to npx, link the Coding Agents docs page

* docs: regenerate 0.9.0 changelog against the release tag; sync docs-skill mirror
2026-08-07 18:23:20 +02:00
Nicolò Boschi b12646f49e Release v0.9.0
- Update version to 0.9.0 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
- Create documentation version-0.9
2026-08-07 18:16:35 +02:00
Nicolò Boschi 404fe467b6 Revert "chore(db): remove deprecated entity schema from memory_links" (#3177) (#3244)
* Revert "chore(db): remove deprecated entity schema from memory_links (#3177)"

This reverts commit 5f8a030615.

* fix(migrations): re-parent f2a6d8c4b1e9 onto e4a7c1b9d2f6 after removing c1e7a9d3f5b2
2026-08-07 18:08:59 +02:00
Nicolò Boschi fecacc4ea8 docs: launch Coding Agents + mark the per-agent plugins superseded (#3162)
* docs: launch the Coding Agents page and mark the per-agent plugins superseded

Hold until the Coding Agents plugin is announced — merging this makes the page
public and tells existing users their plugin is legacy, so it should land with
the announcement rather than before it.

Two halves:

- Launch. Undoes the deliberate hide: the page drops `unlisted`, its entry
  returns to integrations.json (which drives both the gallery and the sidebar),
  and coding-agents leaves the EXCLUDED set in check-integrations.mjs, so the
  released-tag check guards it like every other integration.

- Supersede. The six overlapping integrations — claude-code, codex, opencode,
  cursor-cli, cline, copilot-cli — get an admonition in the style already used
  on the Hermes page: what replaces them, the install command for their harness,
  and a link to the migration section. Pages and packages keep working; nothing
  is deleted and no registry deprecation is published, so existing links and
  installs are unaffected.

Each notice states plainly that memory does not move — the old plugins scope a
bank per agent per project where this one uses a bank per repo — and points at
`--import-conversations` for Claude Code and Codex, the two whose transcripts
record enough to attribute a session to a repo. The other four say so instead of
implying an import exists.

* docs: group the integrations sidebars into coding agents / frameworks / apps

Both sidebars driven by integrations.json rendered one flat run of 59 entries.
Split them into three groups so a coding agent is distinguishable from an SDK.

The existing `category` field couldn't drive this on its own: its `tool` bucket
mixed CLI agents and editors with chat apps, note-taking and voice platforms.
The 19 coding agents move to a new `coding-agent` category; `framework` is
unchanged, and `tool`/`mcp` become the catch-all group.

Grouping lives in src/lib/integration-groups.ts, kept free of the @site alias and
of any JSON import so both consumers can use it — the theme swizzle (webpack) and
sidebars-integrations.ts (evaluated at config load). An unrecognised category
falls into the last group rather than disappearing from the sidebar.

* docs: regenerate the docs-skill mirror for the supersede admonitions

scripts/generate-docs-skill.sh mirrors docs-integrations/ into skills/; the
launch commit edited seven pages without re-running it, so the mirror still
described the per-agent plugins as current.

* docs: spell out the migration path for Claude Code and Codex

Both pages said memory "does not move automatically", which is now only half
true: the server endpoint IS carried over (~/.hindsight/claude-code.json and
~/.hindsight/codex.json, same keys), so nobody is silently switched to Cloud.

Each page now states what moves — the endpoint automatically, conversations via
--import-conversations — and what does not: the recall/retain settings, missions
and bank-naming options. It also says why conversations come from local
transcripts rather than the old bank: that bank defaulted to a single static
bank shared by every project, whose documents record only a session id, so
attributing them to a repo requires the local transcripts regardless.

These are the only two superseded plugins with an endpoint to carry; the other
four pages already say their history can't be imported and are unchanged.

* feat(claude-code,codex): deprecation notice in the old plugins' sessions

Folded in from #3205 so the launch lands as one change: the docs that announce
the Coding Agents plugin and the in-session notice that points existing users at
them ship together, rather than one arriving without the other.

Both plugins keep working, but they are deprecated — development has moved to
@vectorize-io/hindsight-coding-agents. A changelog entry reaches nobody who
installed a year ago, so the SessionStart hook says it, via systemMessage (the
channel Claude Code shows the USER; additionalContext would only reach the
model). Codex accepts the same hook output shape, so one design serves both.

Emitted before the existing early returns: neither the memory settings nor
whether the server is reachable changes the fact that the plugin is deprecated.

Shown every session, with `"upgradeNotice": false` as the permanent opt-out —
stated in the message itself, which is what makes that frequency acceptable.
With no rate limit there is no state file and none of its failure modes; what
remains is a config check that returns None rather than raising, because a
promotional message must never be why a session breaks.

* docs: per-harness install sections, featured hub cards, browsable sidebar

Page
- One subsection per harness with its logo and a copyable install command,
  replacing the table: the command is what a reader came for, and a table cell
  is not copyable.
- Title is just "Coding Agents"; the old keyword-stuffed title read as spam in
  the sidebar and breadcrumbs.
- "Ingestion internals (no CLI)" is dropped from the docs page via the existing
  DROP_SECTIONS mechanism, staying in the README where the contributor-facing
  audience is.

Integrations Hub
- A Featured grid pins Coding Agents, Vercel AI SDK and OpenClaw above the
  rest, and only on the unfiltered view — pinned cards above non-matching
  search results would read as noise.
- The Coding Agents card draws all ten supported harness logos. "One install,
  every agent" is the whole pitch and a single icon cannot carry it.
- Logos come from the control plane's harness set, which is already keyed by the
  exact harness ids the plugin uses, so the two stay consistent by construction.

Sidebar
- Groups are open but show six entries each, with the tail behind a nested
  "Show all N". Fully expanded, 59 entries were a wall; fully collapsed hid that
  the list was worth opening.
- The umbrella Coding Agents entry leads its group instead of sorting under "C",
  since it is the entry point to every other agent in that list.

* fix(docs): point the page's harness logos at this build, not production

The README must use absolute URLs so the logos render on npm and GitHub, but the
docs page inherited them verbatim — pinning every image to hindsight.vectorize.io,
where /img/harness/* does not exist yet. Logos were broken locally and in
previews, and would only start working after a deploy.

The sync script now rewrites our own absolute asset URLs to site-relative, next
to the repo-relative-link rewrite it already does for the same reason: two
audiences needing different URLs from one source.

* docs: curate the sidebar previews, harness logos for coding agents

The coding-agent group now previews HARNESSES, not pages: ten logos that all
link to the Coding Agents page. Listing ten integration pages there presented
one plugin as ten separate integrations, which is the opposite of its pitch —
and the logos make the group recognisable at a glance. Every individual page
moves behind "Show all", which is also what keeps it associated with the
sidebar.

The other groups get hand-picked previews instead of the first six
alphabetically — the first names in a sorted list are an accident of spelling,
not a description of the group:
  Frameworks & SDKs — LangGraph/LangChain, Vercel AI SDK, Vercel Chat, Eve, CrewAI
  Apps & tools      — ChatGPT, Hermes, OpenClaw, Obsidian

Hermes, NemoClaw, OpenClaw and Paperclip move from framework to tool.

The overflow label counts what opening it actually reveals: "Show 21 more" where
some entries are already previewed above, "Show all 19" for coding agents, whose
overflow really is every page.

* docs: inline sidebar preview, full list on the integration pages

The two sidebars do different jobs, so they now show different things.

Main docs sidebar — a preview: three groups rendered INLINE and
non-collapsible, nothing behind a disclosure. Ten harness logos for coding
agents, five frameworks, four apps, then an "All integrations" link to the
gallery, which offers search and filters a sidebar cannot.

Integration pages — the full list again: flat, alphabetical, every entry. Once
you are on one of these pages you are comparing and hopping between them, so
hiding two thirds behind "Show N more" worked against the reader. Listing each
page directly is also what associates it with this sidebar.

* docs: promote the sidebar groups, separate Featured, brand the umbrella card

Sidebar: the "Integrations" placeholder is replaced BY its contents instead of
filled, so the three groups sit at the same level as the rest of the navigation.
The wrapper was two levels of nesting to say one thing, and it indented every
entry beneath it.

Hub: a divider and an "All integrations" heading separate the pinned Featured
cards from the full list, which otherwise read as one uninterrupted run.

The Coding Agents card carries the Hindsight mark rather than the GitHub logo —
it is our own package, and the GitHub icon said nothing about it.

* docs: install with npx, no global install

Every example across the README, the seven integration pages, the companion
skill and the generated docs now runs the installer with npx. Nothing here asks
anyone to keep a package installed whose only job is to wire other tools up.

The paragraph telling people to install globally — and warning that npx was
refused — is replaced by what actually happens: install copies what it needs
into ~/.hindsight/coding-agents and points each agent's wiring there, so it does
not matter where it ran from, and updating is the same command again.

Depends on #3241, which makes that staging real; until it ships in 0.0.6 the
published installer still refuses to run from an npx cache.

* docs: move the superseded pages into a Legacy section, out of the gallery

The six per-agent pages the Coding Agents plugin replaces — Claude Code, Codex,
Cursor CLI, Copilot CLI, opencode, Cline — move to a `legacy` category.

They keep their pages and their migration banners: people still run these
plugins and still arrive from old links, so removing the pages would break both.
What changes is where they are offered. The gallery is where someone comes to
CHOOSE an integration, and offering one we are actively migrating them off
points them at a dead end — so legacy entries are filtered out of it, including
the hero banner, whose hardcoded list still advertised Claude Code and now
advertises the plugin that replaced it.

In the sidebar they sit in a collapsed "Legacy" section at the end instead of
mixed in alphabetically, so the main list is only what we would recommend today.
Docusaurus expands that section automatically when you are on one of the pages.

Grouping keys off an explicit `harnessPreview` flag now: "no previewIds" used to
imply the coding-agent group, which the Legacy group would also have matched.
2026-08-07 17:00:50 +02:00
Ben 4d52d2974c docs(templates): recommend a few relevant integrations on the Hermes-only templates (#3215)
Four bank templates (hermes-gateway-bot, hermes-orchestrator, customer-support,
research-assistant) listed only `hermes`, making them look Hermes-exclusive even
though their memory pattern fits other harnesses. Add a small, curated set of the
most relevant integrations to each (keeping `hermes`):

- hermes-gateway-bot: langgraph, crewai, agno
- hermes-orchestrator: langgraph, crewai, autogen
- customer-support:   langgraph, crewai, dify
- research-assistant: obsidian, llamaindex, langgraph

Also fixes two stale icon IDs on the conversation template
(ai-sdk -> vercel-ai-sdk, chat -> vercel-chat) so its icons resolve.

`hermes` is kept on all seven templates, so the Hermes setup picker is unaffected.
2026-08-07 10:21:35 -04:00
Nicolò Boschi cb4c4c06b3 release(coding-agents): v0.1.0 2026-08-07 16:10:12 +02:00
Nicolò Boschi 79fe411101 feat(coding-agents): styled installer UI, arrow-key server picker, required Cloud token (#3242)
- clack/Vercel-style rail renderer (src/install-ui.ts, zero-dep): per-harness
  step groups keyed on the '<name>: ' log prefix, severity from message
  phrasing plus run()'s own emoji markers, $HOME shortened to ~, version
  header, honest outros (partial failures say so instead of 'nothing changed')
- arrow-key server picker (❯ pointer, ↑/↓/j/k + Enter, digit shortcuts,
  Esc/q/Ctrl+C cancels) with the numbered prompt kept as fallback when a raw
  TTY is unavailable; rows truncate to the terminal width and autowrap is
  disabled during repaint so narrow terminals don't duplicate lines
- fix: the interactive picker never actually waited (v0.0.4/v0.0.5) —
  process.stdin.isTTY flips fd 0 non-blocking, readSync EAGAINs, and every
  answer silently became its default. Probe with tty.isatty instead and treat
  EAGAIN as wait-for-input
- fix: configureServer now honors HINDSIGHT_CONFIG like the runtime, so the
  wizard writes the file sessions actually read
- Hindsight Cloud API token is now REQUIRED: interactive re-asks (3 attempts),
  --server cloud without --api-token refuses up front instead of writing a
  config that 401s on the first session
- installSkill logs with the harness prefix so skill lines group correctly
2026-08-07 16:08:43 +02:00
687 changed files with 39595 additions and 3192 deletions
+19
View File
@@ -35,6 +35,25 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# Disable it only for those backends; consolidation still enforces the cap.
# HINDSIGHT_API_LLM_SUPPORTS_MAX_ITEMS=true
# Pin a conversation to one backend prompt cache (OpenAI-compatible providers only).
# Server-side prompt caches are per backend server, so the same conversation has to
# reach the same one to hit. Values: auto (default), xai_conv_id (sends xAI's
# x-grok-conv-id header), openai_prompt_cache_key (sends OpenAI's prompt_cache_key
# field), none (sends nothing). "auto" picks from the base URL host and is an
# allowlist: x.ai / grok.com get the header, native OpenAI / openai.com / Azure
# OpenAI get the field, and every other backend gets nothing. Per-operation
# overrides take precedence. Set to none to disable entirely.
# HINDSIGHT_API_LLM_CACHE_AFFINITY=auto
# HINDSIGHT_API_RETAIN_LLM_CACHE_AFFINITY=none
# HINDSIGHT_API_REFLECT_LLM_CACHE_AFFINITY=xai_conv_id
# HINDSIGHT_API_CONSOLIDATION_LLM_CACHE_AFFINITY=none
# Ask litellm/litellmrouter/bedrock for structured output via a forced tool call
# instead of response_format. Enable it for backends that reject response_format
# outright -- e.g. Bedrock Claude in ap-southeast-2 ("Extra inputs are not permitted");
# the same model in us-east-1 accepts response_format and needs nothing here.
# HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL=false
# 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.
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

+16
View File
@@ -1000,6 +1000,22 @@
{
"date": "2026-08-07",
"stars": 19183
},
{
"date": "2026-08-08",
"stars": 19271
},
{
"date": "2026-08-09",
"stars": 19334
},
{
"date": "2026-08-10",
"stars": 19414
},
{
"date": "2026-08-11",
"stars": 19506
}
]
}
+157 -12
View File
@@ -25,7 +25,7 @@ jobs:
cli: ${{ steps.filter.outputs.cli }}
docker: ${{ steps.filter.outputs.docker }}
helm: ${{ steps.filter.outputs.helm }}
docs: ${{ steps.filter.outputs.docs }}
doc-examples: ${{ steps.filter.outputs.doc-examples }}
embed: ${{ steps.filter.outputs.embed }}
all-npm: ${{ steps.filter.outputs.all-npm }}
hindsight-all: ${{ steps.filter.outputs.hindsight-all }}
@@ -43,6 +43,7 @@ jobs:
integrations-continue: ${{ steps.filter.outputs.integrations-continue }}
integrations-cursor-cli: ${{ steps.filter.outputs.integrations-cursor-cli }}
integrations-zcode: ${{ steps.filter.outputs.integrations-zcode }}
integrations-agent-plugin: ${{ steps.filter.outputs.integrations-agent-plugin }}
integrations-copilot-cli: ${{ steps.filter.outputs.integrations-copilot-cli }}
integrations-crewai: ${{ steps.filter.outputs.integrations-crewai }}
integrations-litellm: ${{ steps.filter.outputs.integrations-litellm }}
@@ -117,12 +118,17 @@ jobs:
- 'docker/**'
helm:
- 'helm/**'
docs:
- 'hindsight-docs/**'
- '*.md'
# Integration changes can add/rename integrations, which the docs
# build's integrations check validates against integrations.json.
- 'hindsight-integrations/**'
# The RUNNABLE samples only. This replaces a broad `docs` filter that also
# matched 'hindsight-docs/**', '*.md' and 'hindsight-integrations/**' — the
# last of those so an integration rename would be caught by the docs build's
# integrations check, except that build (build-docs) has no `if:` and runs
# unconditionally anyway. test-doc-examples was the filter's only consumer,
# and it executes every sample against a live LLM-backed server, so every
# integration and prose-only PR paid for four provider-credentialed runs that
# none of those files can affect.
doc-examples:
- 'hindsight-docs/examples/**'
- 'scripts/test-doc-examples.sh'
embed:
- 'hindsight-embed/**'
all-npm:
@@ -189,6 +195,8 @@ jobs:
- 'hindsight-integrations/zed/**'
integrations-zcode:
- 'hindsight-integrations/zcode/**'
integrations-agent-plugin:
- 'hindsight-integrations/agent-plugin/**'
integrations-n8n:
- 'hindsight-integrations/n8n/**'
integrations-zapier:
@@ -791,6 +799,29 @@ jobs:
working-directory: ./hindsight-integrations/zcode
run: uv run pytest tests -v
test-agent-plugin-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-agent-plugin == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Validate Agent Plugin manifests
working-directory: ./hindsight-integrations/agent-plugin
run: python3 validate.py
build-ai-sdk-integration:
needs: [detect-changes]
if: >-
@@ -4528,6 +4559,69 @@ jobs:
fi
done || true
# Compatibility gate against hermes-agent's *main* branch.
#
# `hermes memory setup` installs `hindsight-all` into Hermes' own venv to run
# memory in local_embedded mode, and Hermes exact-pins every direct dependency
# (`==X.Y.Z`) as a deliberate supply-chain policy — they will not loosen a pin
# for us. So any version range Hindsight declares that excludes one of their
# pins makes the two impossible to co-install for every Hermes user on
# embedded memory. That was #3251: our `cryptography>=48.0.1` / `pillow>=12.3.0`
# against their `==46.0.7` / `==12.2.0`, which left `pip check` permanently
# broken. Both sides bump on their own schedule, so this needs a standing gate
# rather than a one-off fix; tracking main surfaces the next collision while
# it is still cheap to fix on either side.
#
# Deliberately not gated on has_secrets — the resolution, wiring, runtime and
# daemon-boot checks need no credentials, so this runs on fork PRs too. Only
# retain/recall need an LLM and the script skips them when no key is present.
test-hermes-compat:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.embed == 'true' ||
needs.detect-changes.outputs.hindsight-all == 'true' ||
needs.detect-changes.outputs.clients-python == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 45
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
# hindsight-all pulls the local-ml extra, so the embedded daemon can load
# sentence-transformers models. Cache them like the test-embed job does.
- name: Cache HuggingFace models
uses: actions/cache@v5
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-hermes-compat-${{ hashFiles('hindsight-api-slim/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-hermes-compat-
${{ runner.os }}-huggingface-
- name: Run Hermes compatibility test
run: ./scripts/test-hermes-compat.sh
- name: Collect embedded daemon logs on failure
if: failure()
run: |
for f in ~/.hindsight/profiles/hermes-ci*.log ~/.hindsight/profiles/hermes-ci*.stderr.log; do
if [ -f "$f" ]; then
echo "=== $f ==="
cat "$f"
fi
done || true
test-hindsight-all:
needs: [detect-changes]
if: >-
@@ -4629,7 +4723,7 @@ jobs:
needs.detect-changes.outputs.clients-python == 'true' ||
needs.detect-changes.outputs.clients-go == 'true' ||
needs.detect-changes.outputs.cli == 'true' ||
needs.detect-changes.outputs.docs == 'true' ||
needs.detect-changes.outputs.doc-examples == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -5033,13 +5127,26 @@ jobs:
exit 0
fi
echo "Checking OpenAPI compatibility against base branch: $BASE_BRANCH"
# Compare against the point this branch was cut from, NOT the live tip of
# the base branch. Using the tip reports every endpoint main has gained
# since the branch was cut as "removed by this PR" — a false positive that
# fails PRs which touch no spec at all, and whose only cure is an unrelated
# rebase. The merge-base answers the question the check actually asks:
# did *this branch* remove something?
MERGE_BASE="$(git merge-base "origin/$BASE_BRANCH" HEAD)"
# Extract the old OpenAPI spec from base branch
git show "origin/$BASE_BRANCH:hindsight-docs/static/openapi.json" > /tmp/old-openapi.json
if [ -z "$MERGE_BASE" ]; then
echo "⚠️ Warning: Could not determine merge-base with $BASE_BRANCH. Skipping compatibility check."
exit 0
fi
echo "Checking OpenAPI compatibility against $BASE_BRANCH merge-base: $MERGE_BASE"
# Extract the old OpenAPI spec from the merge-base
git show "$MERGE_BASE:hindsight-docs/static/openapi.json" > /tmp/old-openapi.json
if [ ! -s /tmp/old-openapi.json ]; then
echo "⚠️ Warning: Could not find OpenAPI spec in base branch. Skipping compatibility check."
echo "⚠️ Warning: Could not find OpenAPI spec at the merge-base. Skipping compatibility check."
exit 0
fi
@@ -5081,6 +5188,42 @@ jobs:
cd hindsight-dev
uv run cli-coverage-check
# hindsight-dev/tests had no job of its own, so nothing ran it: the benchmark
# harness was only exercised by the nightly Performance Tests workflow, where a
# plain construction bug in the answer/judge LLM config surfaced as a red
# benchmark hours later instead of on the PR that introduced it.
test-dev:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.dev == '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
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Install hindsight-dev dependencies
run: |
cd hindsight-dev && uv sync --frozen --extra test --index-strategy unsafe-best-match
- name: Run hindsight-dev tests
working-directory: hindsight-dev
run: uv run pytest tests/ -v
# Report CI status back to the PR for pull_request_review events.
# GitHub does not automatically link pull_request_review check runs to the PR,
# so we create a commit status on the PR head SHA and post a comment.
@@ -5100,6 +5243,7 @@ jobs:
- test-codex-integration
- test-cursor-cli-integration
- test-zcode-integration
- test-agent-plugin-integration
- build-ai-sdk-integration
- test-ai-sdk-integration-deno
- test-opencode-integration
@@ -5155,6 +5299,7 @@ jobs:
- test-embed
- test-embed-windows
- verify-embed-control-center-bundle
- test-hermes-compat
- test-hindsight-all
- test-hindsight-agent-sdk
- test-claude-agent-sdk-integration
+4 -14
View File
@@ -3,21 +3,11 @@
# pgroonga is a multilingual full-text search extension built on Groonga.
# It works out of the box for CJK (Chinese, Japanese, Korean) and other
# non-whitespace-segmented languages via the TokenBigram tokenizer.
FROM groonga/pgroonga:latest-debian-pg17
FROM groonga/pgroonga:4.0.8-debian-17
# Install pgvector on top of the pgroonga base image (which already provides
# pgroonga and the Groonga library).
# pgroonga, the Groonga library, and the PostgreSQL PGDG package repository).
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
git \
postgresql-server-dev-17 \
postgresql-17-pgvector=0.8.6-1.pgdg13+1 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN cd /tmp && \
git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git && \
cd pgvector && \
make && \
make install
RUN rm -rf /tmp/pgvector && \
apt-get purge -y --auto-remove build-essential git postgresql-server-dev-17
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.8.6
appVersion: "0.8.6"
version: 0.9.0
appVersion: "0.9.0"
keywords:
- ai
- memory
+15 -4
View File
@@ -36,16 +36,22 @@ api:
cpu: 500m
memory: 1Gi
# Liveness and readiness probes
# Liveness and readiness probes.
# Liveness uses /health/live, which performs no database access: a slow or
# unreachable database must gate traffic (readiness), never restart pods.
# Needs an image from this chart's appVersion or newer — older ones serve
# /health only, and would fail this probe with a 404.
livenessProbe:
httpGet:
path: /health
path: /health/live
port: 8888
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# Readiness checks the database, so a pod that cannot reach it is pulled out
# of the Service and put back once the database recovers.
readinessProbe:
httpGet:
path: /health
@@ -131,10 +137,15 @@ worker:
cpu: 500m
memory: 1Gi
# Liveness and readiness probes
# Liveness and readiness probes.
# Liveness uses /health/live, which performs no database access. Restarting a
# worker whose database is merely slow requeues its claimed operations with
# retry_count incremented, so DB checks must stay out of liveness.
# Needs an image from this chart's appVersion or newer — older ones serve
# /health only, and would fail this probe with a 404.
livenessProbe:
httpGet:
path: /health
path: /health/live
port: 8889
initialDelaySeconds: 30
periodSeconds: 10
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-all",
"version": "0.8.6",
"version": "0.9.0",
"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",
+3 -3
View File
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-all-slim"
version = "0.8.6"
version = "0.9.0"
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
license = "MIT"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim==0.8.6",
"hindsight-api-slim==0.9.0",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
"hindsight-embed==0.9.0",
]
[tool.uv.sources]
+4 -4
View File
@@ -4,15 +4,15 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-all"
version = "0.8.6"
version = "0.9.0"
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
license = "MIT"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim[all]==0.8.6",
"hindsight-api-slim[all]==0.9.0",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
"hindsight-embed==0.9.0",
]
[tool.uv.sources]
@@ -22,7 +22,7 @@ hindsight-embed = { workspace = true }
[project.optional-dependencies]
local-llm = [
"hindsight-api-slim[local-llm]==0.8.6",
"hindsight-api-slim[local-llm]==0.9.0",
]
test = [
"pytest>=7.0.0",
+1 -1
View File
@@ -53,4 +53,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.8.6"
__version__ = "0.9.0"
@@ -0,0 +1,23 @@
"""Text-search SQL shapes shared by index DDL and the queries that must hit it.
A PostgreSQL expression index is only selectable when the query repeats the
indexed expression verbatim, so the DDL (``migrations.py`` and the Alembic
versions) and the read arms (``engine/sql/postgresql.py``) cannot be allowed to
drift. Both sides call the helpers here — same idea as
``_pg_search.pg_search_bm25_columns``.
"""
def mental_models_text_document(alias: str | None = None) -> str:
"""The ``mental_models`` full-text document: model/page name + content.
Mirrors the generating expression of the native tsvector column created by
the ``n9i0j1k2l3m4`` (learnings / pinned_reflections) migration, so every
backend indexes and queries the exact same document. ``content`` is NOT NULL,
hence the deliberate lack of a ``COALESCE`` around it.
``alias`` qualifies the columns for queries that join the table (``mm``);
leave it unset for DDL, where the expression is already table-scoped.
"""
prefix = f"{alias}." if alias else ""
return f"(COALESCE({prefix}name, '') || ' ' || {prefix}content)"
@@ -847,7 +847,8 @@ def import_bank_command(
f"Imported bank '{result.bank_id}': {result.documents_imported} doc(s), "
f"{result.facts_imported} fact(s), {result.observations_imported} observation(s), "
f"{result.mental_models_imported} mental model(s), "
f"{result.mental_model_history_imported} mm-history row(s), {result.directives_imported} directive(s), "
f"{result.mental_model_history_imported} mm-history row(s), "
f"{result.knowledge_pages_imported} knowledge page(s), {result.directives_imported} directive(s), "
f"{result.webhooks_imported} webhook(s), {result.history_rows_imported} history row(s)"
)
@@ -1,268 +0,0 @@
"""Drop the deprecated entity schema from ``memory_links``.
Entity edges are no longer materialized in ``memory_links``. Retain stores
memory-to-entity associations in ``unit_entities``, and both read paths derive
entity edges on demand from that table — the /graph endpoint builds them from
shared ``unit_entities`` rows, and recall expands via the ``unit_entities``
self-join. Migration ``e9b2c7d1f3a4`` deleted the materialized entity rows and
current writers only ever pass ``entity_id = NULL``, so the entity-specific
schema on ``memory_links`` is now dead weight:
- the ``entity_id`` column and its FK to ``entities``
- ``link_type = 'entity'`` in the table CHECK constraint
- the entity index (``idx_memory_links_entity`` on PG, ``idx_ml_entity`` on Oracle)
- the ``entity_id`` term in the function-based ``idx_memory_links_unique``
Once entity edges are gone, every remaining row (temporal, semantic, and the
causal types) has a single meaningful identity — ``(from_unit_id, to_unit_id,
link_type)`` — so the unique index collapses to those three columns and
preserves the existing effective uniqueness semantics.
Production ``memory_links`` tables and the entity index can be very large, so
this migration is written to avoid long exclusive locks: the residual delete is
chunked with per-batch commits, every index is swapped with ``CONCURRENTLY``,
and the new CHECK is added ``NOT VALID`` then validated separately so writers are
never blocked on a full-table scan.
Downgrade restores the former schema *shape* (column, FK, index, CHECK, and the
expression unique index) but cannot reconstruct the historical entity rows that
``e9b2c7d1f3a4`` already deleted — new retains do not produce them either, so the
restored entity index would simply stay empty.
Revision ID: c1e7a9d3f5b2
Revises: e4a7c1b9d2f6
Create Date: 2026-08-04
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c1e7a9d3f5b2"
down_revision: str | Sequence[str] | None = "e4a7c1b9d2f6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_NIL_ENTITY_UUID = "00000000-0000-0000-0000-000000000000"
_LINK_TYPES_WITHOUT_ENTITY = "'temporal', 'semantic', 'causes', 'caused_by', 'enables', 'prevents'"
_LINK_TYPES_WITH_ENTITY = "'temporal', 'semantic', 'entity', 'causes', 'caused_by', 'enables', 'prevents'"
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()
# CONCURRENTLY index swaps and the DO block's per-batch COMMIT both require
# running outside Alembic's migration transaction — an autocommit_block
# commits it and switches the connection to autocommit for the duration.
with op.get_context().autocommit_block():
# 1. Defensively delete any residual entity rows. e9b2c7d1f3a4 already
# did this, but a bank that predates its deployment can still carry
# them, and they must be gone before the three-column unique index
# (which no longer distinguishes entity rows) and the entity-free
# CHECK are created. Chunked with per-batch commits so a very large
# table drains in bounded transactions instead of one long-locking
# delete.
op.execute(
f"""
DO $$
DECLARE
deleted INTEGER;
BEGIN
LOOP
DELETE FROM {schema}memory_links
WHERE ctid IN (
SELECT ctid FROM {schema}memory_links
WHERE link_type = 'entity'
LIMIT 50000
);
GET DIAGNOSTICS deleted = ROW_COUNT;
EXIT WHEN deleted = 0;
COMMIT;
END LOOP;
END$$;
"""
)
# 2. Drop the entity indexes. idx_memory_links_entity_covering was
# already removed by e1b2c3d4f5a6; dropped again defensively.
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_links_entity")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_links_entity_covering")
# 3. Replace the expression unique index with the three-column form.
# Build the new one first (under a temporary name) so uniqueness is
# never unprotected, then drop the old expression index and rename.
# Non-entity rows already collapse to (from, to, link_type) under the
# old COALESCE(entity_id, nil) expression, so this cannot find a
# duplicate once the entity rows are gone.
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_links_unique_new")
op.execute(
f"CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_links_unique_new "
f"ON {schema}memory_links (from_unit_id, to_unit_id, link_type)"
)
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_links_unique")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_memory_links_unique_new RENAME TO idx_memory_links_unique")
# 4. Drop the FK and the now-unreferenced column. Both are metadata-only
# on PostgreSQL (DROP COLUMN marks the attribute dropped, no rewrite).
op.execute(f"ALTER TABLE {schema}memory_links DROP CONSTRAINT IF EXISTS fk_memory_links_entity_id_entities")
op.execute(f"ALTER TABLE {schema}memory_links DROP COLUMN IF EXISTS entity_id")
# 5. Recreate the link_type CHECK without 'entity'. Added NOT VALID then
# validated separately: the ADD takes a brief lock without scanning,
# and VALIDATE takes only SHARE UPDATE EXCLUSIVE, so concurrent reads
# and writes are never blocked on the scan.
op.execute(f"ALTER TABLE {schema}memory_links DROP CONSTRAINT IF EXISTS memory_links_link_type_check")
op.execute(
f"ALTER TABLE {schema}memory_links ADD CONSTRAINT memory_links_link_type_check "
f"CHECK (link_type IN ({_LINK_TYPES_WITHOUT_ENTITY})) NOT VALID"
)
op.execute(f"ALTER TABLE {schema}memory_links VALIDATE CONSTRAINT memory_links_link_type_check")
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
with op.get_context().autocommit_block():
# Restore the column and FK (nullable, as in the initial schema).
op.execute(f"ALTER TABLE {schema}memory_links ADD COLUMN IF NOT EXISTS entity_id uuid")
op.execute(f"ALTER TABLE {schema}memory_links DROP CONSTRAINT IF EXISTS fk_memory_links_entity_id_entities")
op.execute(
f"ALTER TABLE {schema}memory_links ADD CONSTRAINT fk_memory_links_entity_id_entities "
f"FOREIGN KEY (entity_id) REFERENCES {schema}entities (id) ON DELETE CASCADE"
)
# Restore the entity partial index.
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_links_entity "
f"ON {schema}memory_links (entity_id) WHERE entity_id IS NOT NULL"
)
# Restore the expression unique index (entity_id back in the key).
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_links_unique_old")
op.execute(
f"CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_links_unique_old "
f"ON {schema}memory_links (from_unit_id, to_unit_id, link_type, "
f"COALESCE(entity_id, '{_NIL_ENTITY_UUID}'::uuid))"
)
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_links_unique")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_memory_links_unique_old RENAME TO idx_memory_links_unique")
# Restore 'entity' as a permitted link_type.
op.execute(f"ALTER TABLE {schema}memory_links DROP CONSTRAINT IF EXISTS memory_links_link_type_check")
op.execute(
f"ALTER TABLE {schema}memory_links ADD CONSTRAINT memory_links_link_type_check "
f"CHECK (link_type IN ({_LINK_TYPES_WITH_ENTITY})) NOT VALID"
)
op.execute(f"ALTER TABLE {schema}memory_links VALIDATE CONSTRAINT memory_links_link_type_check")
def _oracle_exec_ignoring(sql: str, *ignore_codes: int) -> None:
"""Run a DDL statement, swallowing the given ORA-NNNNN codes for idempotency.
Oracle has no ``IF EXISTS``/``IF NOT EXISTS`` for most objects; the standard
pattern (see e4a7c1b9d2f6) is EXECUTE IMMEDIATE inside a PL/SQL block that
re-raises anything but the expected "already absent"/"already present" code.
"""
conditions = " AND ".join(f"SQLCODE != {code}" for code in ignore_codes)
escaped = sql.replace("'", "''")
op.execute(
f"""
BEGIN
EXECUTE IMMEDIATE '{escaped}';
EXCEPTION WHEN OTHERS THEN
IF {conditions} THEN RAISE; END IF;
END;
"""
)
def _oracle_upgrade() -> None:
# 1. Defensively drain residual entity rows in bounded, committed chunks so a
# large table doesn't delete under one long-held lock / huge undo segment.
op.execute(
"""
BEGIN
LOOP
DELETE FROM memory_links WHERE link_type = 'entity' AND ROWNUM <= 50000;
EXIT WHEN SQL%ROWCOUNT = 0;
COMMIT;
END LOOP;
END;
"""
)
# 2. Build the three-column unique index under a temporary name before
# dropping the old expression index, then rename. Oracle DDL implicitly
# commits, so ordering it this way keeps duplicate protection continuous
# instead of leaving a gap between drop and create. ONLINE avoids blocking
# concurrent DML during the (potentially large) index builds/drops.
# ORA-00955: name already in use; ORA-01418: index does not exist.
_oracle_exec_ignoring("DROP INDEX idx_memory_links_unique_new", -1418)
_oracle_exec_ignoring(
"CREATE UNIQUE INDEX idx_memory_links_unique_new ON memory_links (from_unit_id, to_unit_id, link_type) ONLINE",
-955,
)
_oracle_exec_ignoring("DROP INDEX idx_memory_links_unique", -1418)
_oracle_exec_ignoring("ALTER INDEX idx_memory_links_unique_new RENAME TO idx_memory_links_unique", -1418)
# 3. Drop the entity index. ORA-01418: index does not exist.
_oracle_exec_ignoring("DROP INDEX idx_ml_entity ONLINE", -1418)
# 4. Drop the entity FK, then the column. ORA-02443: constraint does not
# exist; ORA-00904: column does not exist.
_oracle_exec_ignoring("ALTER TABLE memory_links DROP CONSTRAINT fk_ml_entity", -2443)
_oracle_exec_ignoring("ALTER TABLE memory_links DROP COLUMN entity_id", -904)
# 5. Recreate the link_type CHECK without 'entity'. ORA-02443: constraint
# does not exist; ORA-02264: name already used by an existing constraint.
_oracle_exec_ignoring("ALTER TABLE memory_links DROP CONSTRAINT chk_ml_link_type", -2443)
_oracle_exec_ignoring(
f"ALTER TABLE memory_links ADD CONSTRAINT chk_ml_link_type CHECK (link_type IN ({_LINK_TYPES_WITHOUT_ENTITY}))",
-2264,
)
def _oracle_downgrade() -> None:
# Restore the column, FK, entity index, expression unique index, and the
# 'entity'-permitting CHECK. ORA-01430: column already exists; ORA-00955:
# name already in use; ORA-01418: index does not exist; ORA-02443/-2264:
# constraint absent / name in use.
_oracle_exec_ignoring("ALTER TABLE memory_links ADD (entity_id RAW(16))", -1430)
_oracle_exec_ignoring(
"ALTER TABLE memory_links ADD CONSTRAINT fk_ml_entity "
"FOREIGN KEY (entity_id) REFERENCES entities (id) ON DELETE CASCADE",
-2264,
)
_oracle_exec_ignoring("CREATE INDEX idx_ml_entity ON memory_links (entity_id)", -955)
_oracle_exec_ignoring("DROP INDEX idx_memory_links_unique_old", -1418)
_oracle_exec_ignoring(
"CREATE UNIQUE INDEX idx_memory_links_unique_old ON memory_links ("
"from_unit_id, to_unit_id, link_type, "
"NVL(entity_id, HEXTORAW('00000000000000000000000000000000'))) ONLINE",
-955,
)
_oracle_exec_ignoring("DROP INDEX idx_memory_links_unique", -1418)
_oracle_exec_ignoring("ALTER INDEX idx_memory_links_unique_old RENAME TO idx_memory_links_unique", -1418)
_oracle_exec_ignoring("ALTER TABLE memory_links DROP CONSTRAINT chk_ml_link_type", -2443)
_oracle_exec_ignoring(
f"ALTER TABLE memory_links ADD CONSTRAINT chk_ml_link_type CHECK (link_type IN ({_LINK_TYPES_WITH_ENTITY}))",
-2264,
)
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -1,7 +1,7 @@
"""Repair: drop the stale global memory_units vector index on per-bank backends.
Revision ID: f2a6d8c4b1e9
Revises: c1e7a9d3f5b2
Revises: e4a7c1b9d2f6
Create Date: 2026-08-06
Migration d5e6f7a8b9c0 dropped the global ``idx_memory_units_embedding`` for
@@ -33,7 +33,7 @@ from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "f2a6d8c4b1e9"
down_revision: str | Sequence[str] | None = "c1e7a9d3f5b2"
down_revision: str | Sequence[str] | None = "e4a7c1b9d2f6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
+194 -49
View File
@@ -17,6 +17,7 @@ from typing import Any, Literal, TypeVar
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, Request, UploadFile
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
from hindsight_api.api import page_markdown
from hindsight_api.api.disconnect import ClientDisconnectCancellationMiddleware, get_scope_cancellation_token
@@ -185,6 +186,7 @@ from hindsight_api.engine.response_models import (
from hindsight_api.engine.search.tags import TagGroup, TagsMatch
from hindsight_api.engine.structured_output import validate_response_schema
from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension
from hindsight_api.liveness import LivenessResponse, liveness_response
from hindsight_api.metrics import (
create_metrics_collector,
get_metrics_collector,
@@ -1892,6 +1894,19 @@ class DocumentImportSubmitResponse(BaseModel):
status: str = "pending"
class DocumentExportSubmitResponse(BaseModel):
"""Response for the async document-export endpoint (202).
The export runs in the background; poll the operations endpoint for status.
On completion the operation's ``result_metadata`` carries ``download_url``
(fetch the ZIP from GET /v1/default/files/download/{key}), ``storage_key``,
``byte_size``, and ``filename``.
"""
operation_id: str
status: str = "pending"
class DeleteResponse(BaseModel):
"""Response model for delete operations."""
@@ -2646,6 +2661,24 @@ class BankTemplateConfig(BaseModel):
description="Persist raw source text (documents.original_text / chunks.chunk_text). "
"Set false to keep only derived facts.",
)
enable_auto_consolidation: bool | None = Field(
default=None, description="Automatically consolidate observations after retain"
)
consolidation_max_memories_per_round: int | None = Field(
default=None, description="Max memory units fed into a single consolidation round"
)
consolidation_llm_parallelism: int | None = Field(
default=None, description="Number of consolidation LLM batches processed concurrently"
)
recall_include_chunks: bool | None = Field(default=None, description="Include raw chunks in recall results")
recall_max_tokens: int | None = Field(default=None, description="Max tokens of results returned by recall")
recall_chunks_max_tokens: int | None = Field(
default=None, description="Max tokens of raw chunks returned by recall (when recall_include_chunks is set)"
)
memory_defense: dict | None = Field(
default=None,
description="Memory Defense policy for this bank (validated against the DefensePolicy schema on write)",
)
def get_config_updates(self) -> dict[str, Any]:
"""Return only the fields that were explicitly set (non-None)."""
@@ -4004,17 +4037,24 @@ def _register_routes(app: FastAPI):
# Global exception handler for authentication errors
@app.exception_handler(AuthenticationError)
async def authentication_error_handler(request, exc: AuthenticationError):
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=401,
content={"detail": str(exc)},
)
async def _readiness_response() -> JSONResponse:
"""Shared body of /health and /health/ready: 200 if healthy, 503 if not."""
health = await app.state.memory.health_check()
status_code = 200 if health.get("status") == "healthy" else 503
return JSONResponse(content=health, status_code=status_code)
@app.get(
"/health",
summary="Health check endpoint",
description="Checks the health of the API and database connection",
description="Readiness check: verifies the API can reach the database. "
"Alias of /health/ready. Use /health/live for liveness probes — this one "
"fails whenever the database is unreachable, which must gate traffic, not "
"restart the process.",
tags=["Monitoring"],
)
async def health_endpoint():
@@ -4023,11 +4063,43 @@ def _register_routes(app: FastAPI):
Returns 200 if healthy, 503 if unhealthy.
"""
from fastapi.responses import JSONResponse
return await _readiness_response()
health = await app.state.memory.health_check()
status_code = 200 if health.get("status") == "healthy" else 503
return JSONResponse(content=health, status_code=status_code)
@app.get(
"/health/ready",
summary="Readiness probe",
description="Returns 200 when the API can serve traffic (database reachable), "
"503 otherwise. Identical to /health, which stays supported as its alias.",
tags=["Monitoring"],
operation_id="get_readiness",
)
async def readiness_endpoint():
"""
Readiness probe that verifies database connectivity.
Returns 200 if ready, 503 if not. A 503 should remove this pod from the
Service; it must not restart it.
"""
return await _readiness_response()
@app.get(
"/health/live",
response_model=LivenessResponse,
summary="Liveness probe",
description="Returns 200 whenever the process can serve a request. Performs no "
"database access, so a slow or unreachable database never restarts the pod. "
"Point livenessProbe here and readinessProbe at /health.",
tags=["Monitoring"],
operation_id="get_liveness",
)
async def liveness_endpoint() -> LivenessResponse:
"""
Liveness probe: in-process only, never touches the database.
Answering at all is the check Hindsight serves requests and task work on
one event loop, so a wedged loop cannot respond within the probe timeout.
"""
return liveness_response()
@app.get(
"/version",
@@ -4391,6 +4463,8 @@ def _register_routes(app: FastAPI):
return data
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:
@@ -5012,6 +5086,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:
@@ -5573,39 +5649,26 @@ def _register_routes(app: FastAPI):
):
"""Export a bank's knowledge base as a flat markdown bundle."""
try:
nodes = await app.state.memory.list_knowledge_nodes(bank_id=bank_id, request_context=request_context)
export = await app.state.memory.export_knowledge_base(bank_id=bank_id, request_context=request_context)
files = [
KnowledgePageBundleFile(path=page_markdown.INDEX_FILENAME, content=page_markdown.render_index(nodes))
]
for node in nodes:
if node.get("kind") != "page":
continue
page = await app.state.memory.get_knowledge_page(
bank_id=bank_id, page_id=node["id"], request_context=request_context
KnowledgePageBundleFile(
path=page_markdown.INDEX_FILENAME, content=page_markdown.render_index(export.nodes)
)
if page is None:
continue
]
for page in export.pages:
files.append(
KnowledgePageBundleFile(
path=page_markdown.page_filename(node["id"]), content=page_markdown.render_document(page)
path=page_markdown.page_filename(page.node_id),
content=page_markdown.render_document(page.page),
)
)
if node.get("mental_model_id"):
history = (
await app.state.memory.get_mental_model_history(
bank_id=bank_id,
mental_model_id=node["mental_model_id"],
request_context=request_context,
if page.history:
files.append(
KnowledgePageBundleFile(
path=page_markdown.log_filename(page.node_id),
content=page_markdown.render_log(page.page, page.history),
)
or []
)
if history:
files.append(
KnowledgePageBundleFile(
path=page_markdown.log_filename(node["id"]),
content=page_markdown.render_log(page, history),
)
)
return KnowledgePageBundleResponse(files=files)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
@@ -6982,26 +7045,56 @@ def _register_routes(app: FastAPI):
# greedy GET /documents/{document_id:path} route, which would otherwise
# capture "export"/"import" as a document id.
"/v1/default/banks/{bank_id}/document-transfer",
summary="Export documents",
description="Export documents (extracted facts, entity names, causal links, chunks) from a bank as a "
"transfer ZIP archive. Embeddings and database ids are not included — importing re-embeds with the target "
"bank's model and re-resolves entities. Consolidated observations are excluded unless include_observations=true. "
"Pass document_id query params to export specific documents, or omit to export the whole bank.",
summary="Export documents (removed — use POST .../document-transfer/export)",
description="**Removed.** The synchronous whole-bank export loaded the entire bank into memory and "
"held a database connection for the full request, which could exhaust memory and take down the shared "
"API on large banks. Use the asynchronous POST /v1/default/banks/{bank_id}/document-transfer/export "
"instead: it returns an operation_id, runs the export in the background, and exposes a download URL on "
"completion.",
operation_id="export_documents_sync_removed",
tags=["Document Transfer"],
deprecated=True,
)
async def api_export_documents_removed(
bank_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Removed synchronous export — always 410, pointing at the async endpoint."""
raise HTTPException(
status_code=410,
detail=(
"Synchronous document export has been removed because it could take down the shared API on "
f"large banks. Submit an async export via POST /v1/default/banks/{bank_id}/document-transfer/export, "
f"poll GET /v1/default/banks/{bank_id}/operations/{{operation_id}}, then download the archive from "
"the download_url in the operation's result_metadata."
),
)
@app.post(
"/v1/default/banks/{bank_id}/document-transfer/export",
response_model=DocumentExportSubmitResponse,
status_code=202,
summary="Export documents (async)",
description="Submit an async export of a bank's documents (extracted facts, entity names, causal links, "
"chunks) as a transfer ZIP archive. Embeddings and database ids are not included — importing re-embeds "
"with the target bank's model and re-resolves entities. Runs as a background operation to avoid pinning "
"the API on large banks. Returns an operation_id; poll "
"GET /v1/default/banks/{bank_id}/operations/{operation_id}. On completion the operation's result_metadata "
"carries download_url (fetch the ZIP from GET /v1/default/files/download/{key}), storage_key, byte_size, "
"and filename. Pass document_id query params to export specific documents, or omit to export the whole "
"bank; include_observations=true also carries consolidated observations (whole-bank export only).",
operation_id="export_documents",
tags=["Document Transfer"],
responses={200: {"content": {"application/zip": {}}, "description": "Transfer archive"}},
)
async def api_export_documents(
bank_id: str,
document_id: list[str] | None = Query(default=None, description="Document id(s) to export; omit for all"),
include_observations: bool = Query(
default=False, description="Also export consolidated observations (restored on import)"
default=False, description="Also export consolidated observations (restored on import; whole-bank only)"
),
request_context: RequestContext = Depends(get_request_context),
):
"""Export documents from a bank into a transfer ZIP archive."""
from fastapi.responses import Response
"""Submit an async document-export operation for a bank."""
try:
if not get_config().enable_document_export_api:
raise HTTPException(
@@ -7016,7 +7109,7 @@ def _register_routes(app: FastAPI):
raise HTTPException(status_code=404, detail=f"Bank '{bank_id}' not found")
try:
archive = await app.state.memory.export_documents_async(
submission = await app.state.memory.submit_export_documents_async(
bank_id,
request_context,
list(document_id) if document_id else None,
@@ -7025,11 +7118,7 @@ def _register_routes(app: FastAPI):
except ValueError as e:
# e.g. include_observations combined with a document_id subset.
raise HTTPException(status_code=400, detail=str(e))
return Response(
content=archive,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{bank_id}-documents.zip"'},
)
return DocumentExportSubmitResponse(operation_id=submission["operation_id"])
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
@@ -7037,7 +7126,9 @@ def _register_routes(app: FastAPI):
except Exception as e:
import traceback
logger.error(f"Error in GET /v1/default/banks/{bank_id}/document-transfer: {traceback.format_exc()}")
logger.error(
f"Error in POST /v1/default/banks/{bank_id}/document-transfer/export: {traceback.format_exc()}"
)
raise HTTPException(status_code=500, detail=str(e))
@app.post(
@@ -7091,6 +7182,60 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in POST /v1/default/banks/{bank_id}/document-transfer: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/files/download/{key:path}",
summary="Download a stored file (async export archive)",
description="Stream a file previously written to file storage — currently the transfer ZIP produced by "
"an async document export. The key comes from the export operation's result_metadata (storage_key / "
"download_url). Access is authorized against the bank the key belongs to.",
operation_id="download_file",
tags=["Document Transfer"],
responses={200: {"content": {"application/zip": {}}, "description": "Stored file"}},
)
async def api_download_file(
key: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Download a bank-scoped stored file (export archive) by storage key."""
from fastapi.responses import Response
try:
if not get_config().enable_document_export_api:
raise HTTPException(
status_code=404,
detail="Document export API is disabled. "
"Set HINDSIGHT_API_ENABLE_DOCUMENT_EXPORT_API=true to enable.",
)
# Only bank-scoped keys are downloadable. Parse the bank id out of the
# "banks/{bank_id}/..." key (request validation, so it belongs here); the
# engine method then authorizes the caller against that bank and retrieves
# the file, so a caller can't fetch another tenant's or bank's archive
# (IDOR guard). The unguessable uuid in the key is defence in depth, not
# the access control.
parts = key.split("/")
if ".." in parts or len(parts) < 2 or parts[0] != "banks" or not parts[1]:
raise HTTPException(status_code=404, detail="File not found")
bank_id = parts[1]
data = await app.state.memory.retrieve_bank_file(bank_id, key, request_context)
if data is None:
raise HTTPException(status_code=404, detail="File not found")
return Response(
content=data,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{bank_id}-documents.zip"'},
)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
logger.error(f"Error in GET /v1/default/files/download/{key}: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/bank-template-schema",
summary="Get bank template JSON Schema",
@@ -161,6 +161,12 @@ 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"
# Backend prompt-cache pinning for the OpenAI-compatible providers and Fireworks. Server-side
# prompt caches are per backend server, so the same conversation has to reach the same
# one: "xai_conv_id" sends xAI's documented x-grok-conv-id header, and
# "openai_prompt_cache_key" sends OpenAI's prompt_cache_key field. See
# engine/cache_affinity.py. Per-operation variants override the global one.
ENV_LLM_CACHE_AFFINITY = "HINDSIGHT_API_LLM_CACHE_AFFINITY"
# 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
@@ -171,6 +177,11 @@ 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"
# Route structured output through a forced function tool instead of the
# OpenAI-style ``response_format`` on the LiteLLM-backed providers (``litellm``,
# ``litellmrouter``, ``bedrock``). Off by default; see
# DEFAULT_LLM_STRUCTURED_OUTPUT_FORCED_TOOL.
ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL = "HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL"
ENV_LLM_SEND_BANK_AS_USER = "HINDSIGHT_API_LLM_SEND_BANK_AS_USER"
ENV_LLM_OLLAMA_NUM_CTX = "HINDSIGHT_API_LLM_OLLAMA_NUM_CTX"
@@ -220,6 +231,14 @@ DEFAULT_LLM_EXTRA_BODY = None # None = no extra body params; JSON dict merged i
DEFAULT_LLM_DEFAULT_HEADERS = (
None # None = no extra headers; JSON dict passed as default_headers to provider SDK clients
)
# "auto" is safe as a default because it is an allowlist, not a best-effort probe:
# it emits a hint only for hosts documented to accept one (x.ai / grok.com get the
# header, native OpenAI / openai.com / Azure OpenAI get the field) and resolves to
# "none" for every other backend, so vLLM, ollama, groq, openrouter and any custom
# OpenAI-compatible endpoint keep receiving byte-identical requests. Measured on a
# live xAI backend: 29% of a shared prefix cached without the header vs 99% with it,
# so defaulting to "none" silently costs most deployments the benefit.
DEFAULT_LLM_CACHE_AFFINITY = "auto"
def parse_gemini_service_tier(value: str | None) -> str | None:
@@ -316,6 +335,7 @@ ENV_RETAIN_LLM_TIMEOUT = "HINDSIGHT_API_RETAIN_LLM_TIMEOUT"
ENV_RETAIN_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_RETAIN_LLM_LITELLMROUTER_CONFIG"
ENV_RETAIN_LLM_REASONING_EFFORT = "HINDSIGHT_API_RETAIN_LLM_REASONING_EFFORT"
ENV_RETAIN_LLM_EXTRA_BODY = "HINDSIGHT_API_RETAIN_LLM_EXTRA_BODY"
ENV_RETAIN_LLM_CACHE_AFFINITY = "HINDSIGHT_API_RETAIN_LLM_CACHE_AFFINITY"
# Fireworks AI batch inference. Fireworks' batch API is a proprietary
# account-scoped dataset/job REST API on a control-plane host, distinct from the
@@ -340,6 +360,7 @@ ENV_REFLECT_LLM_TIMEOUT = "HINDSIGHT_API_REFLECT_LLM_TIMEOUT"
ENV_REFLECT_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_REFLECT_LLM_LITELLMROUTER_CONFIG"
ENV_REFLECT_LLM_REASONING_EFFORT = "HINDSIGHT_API_REFLECT_LLM_REASONING_EFFORT"
ENV_REFLECT_LLM_EXTRA_BODY = "HINDSIGHT_API_REFLECT_LLM_EXTRA_BODY"
ENV_REFLECT_LLM_CACHE_AFFINITY = "HINDSIGHT_API_REFLECT_LLM_CACHE_AFFINITY"
ENV_CONSOLIDATION_LLM_PROVIDER = "HINDSIGHT_API_CONSOLIDATION_LLM_PROVIDER"
ENV_CONSOLIDATION_LLM_API_KEY = "HINDSIGHT_API_CONSOLIDATION_LLM_API_KEY"
@@ -353,6 +374,7 @@ ENV_CONSOLIDATION_LLM_TIMEOUT = "HINDSIGHT_API_CONSOLIDATION_LLM_TIMEOUT"
ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG"
ENV_CONSOLIDATION_LLM_REASONING_EFFORT = "HINDSIGHT_API_CONSOLIDATION_LLM_REASONING_EFFORT"
ENV_CONSOLIDATION_LLM_EXTRA_BODY = "HINDSIGHT_API_CONSOLIDATION_LLM_EXTRA_BODY"
ENV_CONSOLIDATION_LLM_CACHE_AFFINITY = "HINDSIGHT_API_CONSOLIDATION_LLM_CACHE_AFFINITY"
ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
@@ -720,6 +742,7 @@ WORKER_SLOT_TYPE_DEFAULTS: dict[str, int] = {
"refresh_mental_model": 0,
"graph_maintenance": 0,
"import_documents": 0,
"export_documents": 0,
}
@@ -874,6 +897,7 @@ PROVIDER_DEFAULT_MODELS = {
"requesty": "openai/gpt-4o-mini",
"fireworks": "accounts/fireworks/models/llama-v3p1-8b-instruct",
"nous": "deepseek/deepseek-v4-flash",
"xai-oauth": "grok-4.5",
}
DEFAULT_LLM_MODEL = "gpt-4o-mini" # Fallback if provider not in table
# Built-in llama.cpp defaults
@@ -892,6 +916,17 @@ DEFAULT_LLAMACPP_EXTRA_ARGS = None # Space-separated extra CLI args for llama.c
DEFAULT_LLM_STRICT_SCHEMA = False
DEFAULT_LLM_SUPPORTS_MAX_ITEMS = True
# True = ask LiteLLM-backed providers for structured output via a single forced
# function tool (the response schema becomes the tool's parameters) instead of
# the OpenAI-style ``response_format``. Needed where the backend rejects the
# response_format route outright — notably Bedrock Claude, whose Converse layer
# refuses the translated ``outputConfig`` ("Extra inputs are not permitted") while
# accepting the identical schema as a tool (issue #3300). Verified region-dependent:
# ap-southeast-2 / au.* rejects it, us-east-1 / us.* accepts it, so this is opt-in
# rather than keyed off the provider. Default False keeps ``response_format``, which
# every other LiteLLM backend handles natively.
DEFAULT_LLM_STRUCTURED_OUTPUT_FORCED_TOOL = False
DEFAULT_LLM_MAX_CONCURRENT = 32
DEFAULT_LLM_MAX_RETRIES = 3 # Max retry attempts for LLM API calls
DEFAULT_LLM_INITIAL_BACKOFF = 1.0 # Initial backoff in seconds for retry exponential backoff
@@ -1679,6 +1714,7 @@ class LLMMemberConfig:
default_headers: dict | None
bedrock_service_tier: str | None
gemini_service_tier: str | None
cache_affinity: str | None = None
vertexai_project_id: str | None = None
vertexai_region: str | None = None
vertexai_service_account_key: str | None = None
@@ -1772,6 +1808,7 @@ def _parse_llm_members(prefix: str) -> list[LLMMemberConfig]:
reasoning_effort=os.getenv(base + "REASONING_EFFORT") or None,
extra_body=json.loads(os.getenv(base + "EXTRA_BODY", "null")),
default_headers=json.loads(os.getenv(base + "DEFAULT_HEADERS", "null")),
cache_affinity=os.getenv(base + "CACHE_AFFINITY") or None,
bedrock_service_tier=os.getenv(base + "BEDROCK_SERVICE_TIER") or None,
gemini_service_tier=(
parse_gemini_service_tier(gemini_service_tier) if provider.lower() == "gemini" else None
@@ -2079,6 +2116,12 @@ class HindsightConfig:
llm_default_headers: (
dict | None
) # Custom headers passed as default_headers to provider SDK clients (e.g. {"X-Component-Id": "hindsight"} for proxies / request tracing)
# Backend prompt-cache pinning for the OpenAI-compatible providers and Fireworks:
# "none" (default),
# "xai_conv_id", "openai_prompt_cache_key" or "auto". Static (server-level) like the
# two fields above -- it is a transport detail of the configured endpoint, not a
# per-bank behaviour. See ENV_LLM_CACHE_AFFINITY and engine/cache_affinity.py.
llm_cache_affinity: str | None
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
@@ -2090,6 +2133,10 @@ class HindsightConfig:
default=DEFAULT_LLM_SUPPORTS_MAX_ITEMS,
kw_only=True,
) # Whether structured-output schemas accept JSON Schema maxItems
llm_structured_output_forced_tool: bool = field(
default=DEFAULT_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
kw_only=True,
) # LiteLLM-backed providers: structured output via a forced tool call, not response_format
# 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
@@ -2150,6 +2197,7 @@ class HindsightConfig:
retain_llm_litellmrouter_config: dict | None
retain_llm_reasoning_effort: str | None
retain_llm_extra_body: dict | None
retain_llm_cache_affinity: str | None
# Fireworks AI batch inference (static, server-level)
fireworks_account_id: str | None
@@ -2168,6 +2216,7 @@ class HindsightConfig:
reflect_llm_litellmrouter_config: dict | None
reflect_llm_reasoning_effort: str | None
reflect_llm_extra_body: dict | None
reflect_llm_cache_affinity: str | None
consolidation_llm_provider: str | None
consolidation_llm_api_key: str | None
@@ -2181,6 +2230,7 @@ class HindsightConfig:
consolidation_llm_litellmrouter_config: dict | None
consolidation_llm_reasoning_effort: str | None
consolidation_llm_extra_body: dict | None
consolidation_llm_cache_affinity: str | None
# Embeddings
embeddings_provider: str
@@ -3016,6 +3066,7 @@ 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_cache_affinity=os.getenv(ENV_LLM_CACHE_AFFINITY, DEFAULT_LLM_CACHE_AFFINITY) or None,
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),
@@ -3024,6 +3075,10 @@ class HindsightConfig:
ENV_LLM_SUPPORTS_MAX_ITEMS,
DEFAULT_LLM_SUPPORTS_MAX_ITEMS,
),
llm_structured_output_forced_tool=_parse_boolean_env(
ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
DEFAULT_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
),
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(
@@ -3095,6 +3150,7 @@ class HindsightConfig:
retain_llm_litellmrouter_config=_parse_llm_router_config(ENV_RETAIN_LLM_LITELLMROUTER_CONFIG),
retain_llm_reasoning_effort=os.getenv(ENV_RETAIN_LLM_REASONING_EFFORT) or None,
retain_llm_extra_body=json.loads(os.getenv(ENV_RETAIN_LLM_EXTRA_BODY, "null")),
retain_llm_cache_affinity=os.getenv(ENV_RETAIN_LLM_CACHE_AFFINITY) or None,
reflect_llm_provider=os.getenv(ENV_REFLECT_LLM_PROVIDER) or None,
reflect_llm_api_key=os.getenv(ENV_REFLECT_LLM_API_KEY) or None,
reflect_llm_model=os.getenv(ENV_REFLECT_LLM_MODEL)
@@ -3122,6 +3178,7 @@ class HindsightConfig:
reflect_llm_litellmrouter_config=_parse_llm_router_config(ENV_REFLECT_LLM_LITELLMROUTER_CONFIG),
reflect_llm_reasoning_effort=os.getenv(ENV_REFLECT_LLM_REASONING_EFFORT) or None,
reflect_llm_extra_body=json.loads(os.getenv(ENV_REFLECT_LLM_EXTRA_BODY, "null")),
reflect_llm_cache_affinity=os.getenv(ENV_REFLECT_LLM_CACHE_AFFINITY) or None,
consolidation_llm_provider=os.getenv(ENV_CONSOLIDATION_LLM_PROVIDER) or None,
consolidation_llm_api_key=os.getenv(ENV_CONSOLIDATION_LLM_API_KEY) or None,
consolidation_llm_model=os.getenv(ENV_CONSOLIDATION_LLM_MODEL)
@@ -3149,6 +3206,7 @@ class HindsightConfig:
consolidation_llm_litellmrouter_config=_parse_llm_router_config(ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG),
consolidation_llm_reasoning_effort=os.getenv(ENV_CONSOLIDATION_LLM_REASONING_EFFORT) or None,
consolidation_llm_extra_body=json.loads(os.getenv(ENV_CONSOLIDATION_LLM_EXTRA_BODY, "null")),
consolidation_llm_cache_affinity=os.getenv(ENV_CONSOLIDATION_LLM_CACHE_AFFINITY) or None,
# Multi-LLM chains (indexed members + routing strategy)
llm_members=_parse_llm_members(""),
llm_strategy=_parse_llm_strategy(os.getenv(ENV_LLM_STRATEGY)),
@@ -11,8 +11,10 @@ multiple API servers.
import asyncio
import json
import logging
from dataclasses import asdict, replace
from typing import TYPE_CHECKING, Any
from dataclasses import asdict, fields, replace
from functools import lru_cache
from types import UnionType
from typing import TYPE_CHECKING, Any, Union, get_args, get_origin
from hindsight_api.config import (
RECALL_BUDGET_FUNCTIONS,
@@ -295,7 +297,8 @@ class ConfigResolver:
# Only return active overrides for configurable fields. JSON null is a tombstone
# for "Server Default" in the bank-config UI and should not override defaults.
return {k: v for k, v in normalized.items() if k in self._configurable_fields and v is not None}
active = {k: v for k, v in normalized.items() if k in self._configurable_fields and v is not None}
return _coerce_stored_bank_overrides(bank_id, active)
except Exception as e:
logger.error(f"Failed to load bank config for {bank_id}: {e}")
@@ -335,7 +338,7 @@ class ConfigResolver:
k: v for k, v in normalized.items() if k in self._configurable_fields and v is not None
}
if overrides:
result[row["bank_id"]] = overrides
result[row["bank_id"]] = _coerce_stored_bank_overrides(row["bank_id"], overrides)
except Exception as e:
logger.error(f"Failed to bulk-load bank configs: {e}")
return result
@@ -419,6 +422,11 @@ class ConfigResolver:
logger.warning(f"Failed to check permissions for bank {bank_id}: {e}")
# Continue without permission check (fail open for backward compatibility)
# Validate every value against its declared field type before the
# field-specific checks below, so a wrong-shaped value is reported as such
# instead of tripping a structural validator with a confusing message.
_validate_config_value_types(normalized_updates)
# Validate entity_labels structure
if "entity_labels" in normalized_updates and normalized_updates["entity_labels"] is not None:
from .engine.retain.entity_labels import parse_entity_labels
@@ -435,6 +443,16 @@ class ConfigResolver:
raise ValueError(
"Strategy names must not be empty strings. Remove entries with empty names before saving."
)
# A strategy's overrides are applied with dataclasses.replace() at retain
# time, so a wrong-shaped value there wedges the bank exactly as a
# top-level one would. Same contract, same door.
for strategy_name, strategy_overrides in normalized_updates["retain_strategies"].items():
if not isinstance(strategy_overrides, dict):
raise ValueError(f"Invalid retain strategy {strategy_name!r}: must be an object")
try:
_validate_config_value_types(normalize_config_dict(strategy_overrides))
except ValueError as e:
raise ValueError(f"Invalid retain strategy {strategy_name!r}: {e}") from e
# Validate recall budget fields
_validate_recall_budget_updates(normalized_updates)
@@ -530,6 +548,147 @@ class ConfigResolver:
logger.info(f"Reset bank config for {bank_id} to defaults")
# Fields whose accepted input shape is deliberately wider than the dataclass
# annotation, because a dedicated structural validator normalizes them later.
_WIDENED_FIELD_TYPES: dict[str, tuple[type, ...]] = {
# parse_entity_labels() accepts both the bare list of label groups and the
# {"attributes": [...]} envelope, though the field is annotated `list | None`.
"entity_labels": (list, dict),
}
def _runtime_types(declared: Any) -> tuple[type, ...]:
"""Runtime-checkable base classes for a dataclass field annotation.
Unwraps unions (``str | None``) and generic aliases (``list[str]`` -> ``list``);
``None`` is dropped because callers handle the tombstone separately. Returns an
empty tuple for anything not reducible to concrete classes, which the callers
read as "no type contract to enforce".
"""
if declared is type(None):
return ()
origin = get_origin(declared)
if origin in (Union, UnionType):
return tuple(t for arg in get_args(declared) for t in _runtime_types(arg))
if origin is not None:
return (origin,) if isinstance(origin, type) else ()
return (declared,) if isinstance(declared, type) else ()
@lru_cache(maxsize=1)
def _configurable_field_types() -> dict[str, tuple[type, ...]]:
"""Map each configurable field to the value types it accepts."""
configurable = HindsightConfig.get_configurable_fields()
field_types: dict[str, tuple[type, ...]] = {}
for field in fields(HindsightConfig):
if field.name not in configurable:
continue
allowed = _WIDENED_FIELD_TYPES.get(field.name) or _runtime_types(field.type)
if allowed:
field_types[field.name] = allowed
return field_types
def _value_matches_type(value: Any, allowed: tuple[type, ...]) -> bool:
"""Whether ``value`` satisfies a field's declared type contract."""
if isinstance(value, bool):
# bool is an int subclass; it must not slip into a numeric field.
return bool in allowed
if isinstance(value, int) and float in allowed:
# JSON draws no int/float distinction: 1 is a valid ratio.
return True
return isinstance(value, allowed)
# Field types are reported to API clients, so name them the way the JSON payload
# reads rather than by their Python class.
_TYPE_DESCRIPTIONS: dict[type, str] = {
bool: "a boolean",
int: "an integer",
float: "a number",
str: "a string",
list: "a list",
dict: "an object",
}
def _describe_types(allowed: tuple[type, ...]) -> str:
return " or ".join(dict.fromkeys(_TYPE_DESCRIPTIONS.get(t, t.__name__) for t in allowed))
def _validate_config_value_types(updates: dict[str, Any]) -> None:
"""Reject values whose type contradicts the declared HindsightConfig type.
Without this, the bank-config API happily stores e.g. a JSON object in
``observations_mission``; the write succeeds and the bank then fails every
consolidation with ``expected string or bytes-like object, got 'dict'`` from
deep inside prompt assembly (issue #3218). Reject at the door instead, naming
the field and the expected type.
"""
field_types = _configurable_field_types()
for key, value in updates.items():
allowed = field_types.get(key)
# None is the "clear this override" tombstone; unknown keys are rejected
# elsewhere as non-configurable.
if allowed is None or value is None:
continue
if not _value_matches_type(value, allowed):
raise ValueError(f"{key} must be {_describe_types(allowed)}, got {type(value).__name__}")
def _coerce_stored_bank_overrides(bank_id: str, overrides: dict[str, Any], where: str = "") -> dict[str, Any]:
"""Make stored bank overrides safe to consume, tolerating pre-validation shapes.
``_validate_config_value_types`` rejects bad types at write time, but banks
configured before that landed can still hold e.g. a JSON object in a
string-typed field. Every consumer that treats such a value as text blows up
identically on every run (``escape_for_prompt`` -> ``re.sub`` ->
"expected string or bytes-like object, got 'dict'"), so the bank's
consolidation never recovers on its own (issue #3218).
String fields are JSON-encoded, which preserves the author's intent — the
structure still reaches the prompt, as text. Anything else is dropped so the
bank falls back to the tenant/global value rather than wedging.
``where`` labels the location in warnings; it is set when recursing into a
retain strategy, whose overrides reach the same fields via ``apply_strategy``.
"""
field_types = _configurable_field_types()
coerced: dict[str, Any] = {}
for key, value in overrides.items():
allowed = field_types.get(key)
# None passes through: the caller has already dropped top-level tombstones,
# and inside a retain strategy a null is a deliberate override to None.
if allowed is None or value is None or _value_matches_type(value, allowed):
coerced[key] = value
continue
if str in allowed:
coerced[key] = json.dumps(value, ensure_ascii=False)
logger.warning(
f"Bank {bank_id} config field '{key}'{where} holds a {type(value).__name__} but is a string field; "
f"using its JSON encoding. Re-save this field as a string to silence this warning."
)
else:
logger.warning(
f"Bank {bank_id} config field '{key}'{where} holds a {type(value).__name__} but must be "
f"{_describe_types(allowed)}; ignoring the override and falling back to the server default."
)
# Strategy overrides are spliced onto the resolved config by apply_strategy(),
# so a bad value nested there wedges the bank just as a top-level one does.
strategies = coerced.get("retain_strategies")
if isinstance(strategies, dict):
coerced["retain_strategies"] = {
name: (
_coerce_stored_bank_overrides(bank_id, strategy, where=f" in retain strategy {name!r}")
if isinstance(strategy, dict)
else strategy
)
for name, strategy in strategies.items()
}
return coerced
_RECALL_BUDGET_FIXED_KEYS = (
"recall_budget_fixed_low",
"recall_budget_fixed_mid",
@@ -0,0 +1,185 @@
"""Server-side prompt-cache affinity hints for OpenAI-compatible providers.
Prompt caching only pays off when the same conversation reaches the same backend
cache, and providers expose different mechanisms for that:
- xAI stores prompt-cache entries **per backend server** and routes requests
carrying the same ``x-grok-conv-id`` to one server (docs.x.ai, "Maximizing
Cache Hits"). Without it, consecutive calls of one agentic loop can each land
on a cache-cold replica.
- OpenAI accepts a ``prompt_cache_key`` request field that improves its own
cache routing.
Hindsight already does provider-specific cache work for its first-class
providers (``anthropic_llm`` sets ``cache_control`` breakpoints; ``gemini_llm``
runs an explicit ``CachedContent`` manager). This module is the equivalent for
the OpenAI-compatible family — ``OpenAICompatibleLLM`` and its ``fireworks``
and ``nous`` subclasses — which sent no affinity hint at all.
Default ``auto`` per member (``cache_affinity``). ``auto`` is an allowlist, not a
best-effort probe: it emits a hint only for hosts documented to accept one and
resolves to ``none`` for everything else, so an unknown OpenAI-compatible backend
never receives an unfamiliar field. Every helper here is fail-open — when no id
can be derived the request goes out byte-identical to before. Set ``none`` to
disable entirely.
"""
from __future__ import annotations
import hashlib
import json
import logging
from enum import StrEnum
from typing import Any
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
# xAI's documented cache-pinning header, and OpenAI's cache-routing field.
XAI_CONV_ID_HEADER = "x-grok-conv-id"
OPENAI_PROMPT_CACHE_KEY_PARAM = "prompt_cache_key"
# Hosts (exact or parent domain) whose backends implement the xAI header.
_XAI_DOMAINS = ("x.ai", "grok.com")
# Hosts (exact or parent domain) that accept OpenAI's prompt_cache_key field.
_OPENAI_DOMAINS = ("openai.com", "openai.azure.com")
class CacheAffinityMode(StrEnum):
"""How (and whether) to pin a request to a backend prompt cache."""
NONE = "none"
XAI_CONV_ID = "xai_conv_id"
OPENAI_PROMPT_CACHE_KEY = "openai_prompt_cache_key"
AUTO = "auto"
def parse_cache_affinity(value: str | None) -> CacheAffinityMode:
"""Validate a configured cache-affinity mode, defaulting to ``none``.
Raises ``ValueError`` on an unrecognized value so a typo fails loudly at
provider construction rather than silently disabling the feature — the whole
point of the setting is that its effect is invisible in the response.
"""
if not value:
return CacheAffinityMode.NONE
try:
return CacheAffinityMode(value.strip().lower())
except ValueError as e:
valid = ", ".join(mode.value for mode in CacheAffinityMode)
raise ValueError(f"Invalid cache_affinity {value!r}. Must be one of: {valid}.") from e
def _host_matches(hostname: str, domain: str) -> bool:
"""True when ``hostname`` is ``domain`` itself or a subdomain of it.
Parsed-host suffix matching, never a substring test: a bare
``"x.ai" in base_url`` also matches ``vertex.ai`` and
``https://x.ai.evil.example``. The in-tree Azure check
(``".openai.azure.com" in self.base_url``) gets away with a substring only
because its needle is long and dotted; ``x.ai`` is four characters.
"""
return hostname == domain or hostname.endswith(f".{domain}")
def resolve_cache_affinity(mode: CacheAffinityMode, provider: str, base_url: str | None) -> CacheAffinityMode:
"""Resolve ``auto`` to a concrete mode from the provider and base-URL host.
Non-``auto`` modes are returned unchanged. ``auto`` resolves to
``xai_conv_id`` for an x.ai / grok.com host, ``openai_prompt_cache_key`` for
native OpenAI (no base URL) or an openai.com / Azure OpenAI host, and
``none`` for everything else — an unknown backend gets no unfamiliar field.
The xAI check is host-only and deliberately provider-independent: the
documented setup for an xAI endpoint is ``provider=openai`` plus an x.ai base
URL, exactly like Azure OpenAI, so keying on the provider name would miss it.
"""
if mode is not CacheAffinityMode.AUTO:
return mode
hostname = (urlparse(base_url).hostname or "") if base_url else ""
if hostname and any(_host_matches(hostname, domain) for domain in _XAI_DOMAINS):
return CacheAffinityMode.XAI_CONV_ID
if provider.lower() == "openai":
if not hostname:
return CacheAffinityMode.OPENAI_PROMPT_CACHE_KEY
if any(_host_matches(hostname, domain) for domain in _OPENAI_DOMAINS):
return CacheAffinityMode.OPENAI_PROMPT_CACHE_KEY
return CacheAffinityMode.NONE
def _first_message_fingerprint(messages: Any) -> str | None:
"""Hash the first message into a 32-hex id, or None if the shape is wrong.
Used only when no trace context is bound (direct provider use, tests). The
first message is the system prompt, so the id is stable as the message list
grows through an agent loop — which is the property cache pinning needs —
while differing across conversations whose first messages differ.
Shape-checked rather than truthiness-checked: a bare string ``messages``
would index to its first character and mint an id from garbage. Anything
unexpected returns None and the request goes out with no affinity hint.
"""
if not isinstance(messages, list) or not messages or not isinstance(messages[0], dict):
return None
try:
canonical = json.dumps(messages[0], sort_keys=True, ensure_ascii=False, default=str)
except (TypeError, ValueError):
logger.debug("Cache affinity: first message not serializable; sending no hint", exc_info=True)
return None
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32]
def cache_affinity_id(messages: Any) -> str | None:
"""Return the affinity id for the in-flight call, or None to send nothing.
Primary source is the operation's ``trace_id`` — one uuid per
retain/reflect/consolidation run, generated in ``LLMProvider.with_config``
and bound around every underlying provider call, so every LLM call of one
run shares it. That is engine identity rather than payload hashing: it stays
constant across a run even when the first message changes mid-run.
The value is always 32 lowercase hex characters, including for the trace_id
path (hashed rather than passed through) so the wire format is uniform and
carries no uuid semantics.
"""
from .llm_trace import current_trace_context
trace_ctx = current_trace_context()
if trace_ctx is not None and trace_ctx.trace_id:
return hashlib.sha256(str(trace_ctx.trace_id).encode("utf-8")).hexdigest()[:32]
return _first_message_fingerprint(messages)
def apply_cache_affinity(request: dict[str, Any], mode: CacheAffinityMode) -> None:
"""Add this request's cache-affinity hint to ``request`` in place.
``mode`` must already be resolved (see :func:`resolve_cache_affinity`);
``none`` — and an unresolved ``auto`` — add nothing.
User-wins semantics throughout, matching the file's ``setdefault`` precedent
in ``_apply_provider_extra_body_defaults``: an ``x-grok-conv-id`` the caller
already placed in ``extra_headers`` is kept, and a ``prompt_cache_key`` in
the operator's configured ``extra_body`` (the escape hatch for a backend
that wants its own value) suppresses ours entirely.
Never raises: when no id can be derived the request is left byte-identical
to a pre-affinity one.
"""
affinity_id = cache_affinity_id(request.get("messages"))
if affinity_id is None:
return
if mode is CacheAffinityMode.XAI_CONV_ID:
extra_headers = request.setdefault("extra_headers", {})
extra_headers.setdefault(XAI_CONV_ID_HEADER, affinity_id)
elif mode is CacheAffinityMode.OPENAI_PROMPT_CACHE_KEY:
# prompt_cache_key is a first-class named parameter on
# chat.completions.create() in the resolved openai SDK, so it goes at the
# top level rather than through extra_body. An operator value in
# extra_body would still reach the same wire field, so honour it and
# send nothing rather than sending both.
extra_body = request.get("extra_body")
if isinstance(extra_body, dict) and OPENAI_PROMPT_CACHE_KEY_PARAM in extra_body:
return
request.setdefault(OPENAI_PROMPT_CACHE_KEY_PARAM, affinity_id)
@@ -303,7 +303,7 @@ async def _dedup_reconcile_create(
live_source_ids = await _filter_live_source_memories(conn, bank_id, create_source_ids)
if not live_source_ids:
return None
if store.writes_memory_rows_in_sql:
if store.writes_memory_rows_in_sql_for(bank_id):
# Oracle-safe: _native_search_vector_update emits the to_tsvector clause only for a
# native PG tsvector column, "" otherwise (see #3021 — the raw ::regconfig cast
# breaks Oracle). RETURNING-gate on the twin's probe-time text so a concurrent
@@ -385,7 +385,7 @@ async def _dedup_reconcile_update(
store = get_memories()
async with acquire_with_retry(pool) as conn:
async with conn.transaction():
if store.writes_memory_rows_in_sql:
if store.writes_memory_rows_in_sql_for(bank_id):
# Snapshot the updated row's sources with a PLAIN read (no FOR UPDATE). Lock order
# must be sources-before-observation: _filter_live_source_memories below takes
# FOR SHARE on the SOURCE rows first, then the fold UPDATE locks the observation
@@ -583,7 +583,7 @@ async def _filter_live_source_memories(
if not source_memory_ids:
return []
store = get_memories()
if store.writes_memory_rows_in_sql:
if store.writes_memory_rows_in_sql_for(bank_id):
rows = await conn.fetch(
f"SELECT id FROM {fq_table('memory_units')} WHERE id = ANY($1::uuid[]) AND bank_id = $2 FOR SHARE",
source_memory_ids,
@@ -612,7 +612,7 @@ async def _any_live_source_memory(
if not source_memory_ids:
return False
store = get_memories()
if store.writes_memory_rows_in_sql:
if store.writes_memory_rows_in_sql_for(bank_id):
found = await conn.fetchval(
f"SELECT 1 FROM {fq_table('memory_units')} WHERE id = ANY($1::uuid[]) AND bank_id = $2 LIMIT 1",
source_memory_ids,
@@ -732,7 +732,7 @@ async def _count_observations_for_scope(
Observations with no tags are not counted (the limit does not apply to them).
"""
store = get_memories()
if store.writes_memory_rows_in_sql:
if store.writes_memory_rows_in_sql_for(bank_id):
return await conn.fetchval(
f"SELECT COUNT(*) FROM {fq_table('memory_units')} "
f"WHERE bank_id = $1 AND fact_type = 'observation' AND tags @> $2::varchar[]",
@@ -2245,7 +2245,7 @@ async def _execute_update_action(
merged_tags = list(existing_tags | source_tags)
t0 = time.time()
if store.writes_memory_rows_in_sql:
if store.writes_memory_rows_in_sql_for(bank_id):
updated_rows = await conn.execute_rows_affected(
f"""
UPDATE {fq_table("memory_units")}
@@ -2396,7 +2396,7 @@ async def _execute_delete_action(
) -> None:
"""Delete a superseded or contradicted observation."""
store = get_memories()
if store.writes_memory_rows_in_sql:
if store.writes_memory_rows_in_sql_for(bank_id):
await conn.execute(
f"DELETE FROM {fq_table('memory_units')} WHERE id = $1 AND bank_id = $2 AND fact_type = 'observation'",
uuid.UUID(observation_id),
@@ -2782,7 +2782,7 @@ async def _create_observation_directly(
source_memory_ids = live_source_memory_ids
t0 = time.time()
if store.writes_memory_rows_in_sql:
if store.writes_memory_rows_in_sql_for(bank_id):
# Query varies based on text search backend.
from ..schema import _is_oracle # noqa: PLC0415
@@ -25,6 +25,68 @@ from .base import DatabaseConnection
from .result import ResultRow
def graph_maintenance_bank_serialization_sql(table: str, alias: str) -> str:
"""SQL predicate serialising ``graph_maintenance`` claims per bank (#3230).
Every graph_maintenance run is the same bank-wide sweep — the payload carries
only ``bank_id``, and ``run_graph_maintenance_job`` drains the whole queue —
so a second concurrent run for one bank adds no work. It is worse than
useless: ``claim_graph_maintenance_batch`` locks queue rows ``FOR UPDATE``
*without* ``SKIP LOCKED`` (it is written assuming a single runner per bank),
so the runs convoy on each other's row locks while each holds a worker slot.
Same guarantee ``consolidation`` already gets from its ``bank_id != ALL(busy)``
exclusion, and the same caveat: a row wedged in 'processing' holds its bank
until something releases it (``hindsight-admin recover``, or a restart with a
stable ``HINDSIGHT_API_WORKER_ID`` so ``recover_own_tasks`` matches it). That
is a general gap in claim recovery, not specific to graph_maintenance.
Two differences from the consolidation form, both forced by the shape of this
problem:
* It is a **predicate**, not a separate claim phase. Pulling graph_maintenance
into its own phase after the generic shared-pool query would drop it below
every other operation type: it has no reserved-slot floor
(``WORKER_SLOT_TYPE_DEFAULTS`` gives consolidation 2 and graph_maintenance
0), and the poller's fairness pass calls ``claim_tasks`` with
``shared_limit=1``, so a single pending retain would starve it indefinitely.
As a predicate it keeps competing by ``created_at``.
* It also suppresses every same-bank row but the oldest **within one batch**.
Excluding busy banks alone does not: with several pending rows and nothing
yet processing, one batch claims them all — the convoy, unchanged. Several
pending rows per bank are reachable through the recovery paths
(``_reclaim_own_processing_tasks`` resets *all* of a worker's processing
rows in one statement, from ``recover_own_tasks`` at startup and
``release_own_tasks`` at shutdown, plus ``_schedule_retry`` /
``_defer_operation`` / ``hindsight-admin recover``).
The candidate row is always 'pending' and the 'pending' branch is
strictly-older, so the subquery can never match the candidate itself. The
fragment carries no SQL comments on purpose — it is rewritten for Oracle by
regex (``db/oracle.py``).
Args:
table: Fully-qualified async_operations table.
alias: Alias of the outer candidate row in the calling query.
"""
return f"""
({alias}.operation_type <> 'graph_maintenance' OR NOT EXISTS (
SELECT 1 FROM {table} gm_peer
WHERE gm_peer.bank_id = {alias}.bank_id
AND gm_peer.operation_type = 'graph_maintenance'
AND (
gm_peer.status = 'processing'
OR (gm_peer.status = 'pending'
AND gm_peer.task_payload IS NOT NULL
AND (gm_peer.next_retry_at IS NULL OR gm_peer.next_retry_at <= NOW())
AND (gm_peer.created_at < {alias}.created_at
OR (gm_peer.created_at = {alias}.created_at
AND gm_peer.operation_id < {alias}.operation_id)))
)
))
"""
@dataclass
class TagListingParts:
"""Backend-specific SQL fragments for the tag listing query."""
@@ -182,6 +244,7 @@ class DataAccessOps(ABC):
table: str,
sorted_links: list[tuple],
bank_id: str,
nil_entity_uuid: str,
exists_clause: str,
chunk_size: int = 5000,
) -> None:
@@ -600,6 +663,10 @@ class DataAccessOps(ABC):
Oracle implementation uses two-step claims (query busy banks first, then
claim excluding them) to avoid ORA-02014.
Implementations must apply :func:`graph_maintenance_bank_serialization_sql`
to every query that can return a ``graph_maintenance`` row, so at most one
such row per bank is ever in flight.
Args:
consolidation_bank_priority: Per-bank priority for consolidation scheduling.
Maps bank name patterns to integer priorities (higher = claimed first).
@@ -10,7 +10,13 @@ import uuid as uuid_mod
from datetime import UTC, datetime
from .base import DatabaseConnection
from .ops import DataAccessOps, LinkExpansionRows, TagListingParts, UpdatedWindow
from .ops import (
DataAccessOps,
LinkExpansionRows,
TagListingParts,
UpdatedWindow,
graph_maintenance_bank_serialization_sql,
)
from .result import DictResultRow as ResultRow
ORACLE_IN_LIST_LIMIT = 1000
@@ -142,6 +148,7 @@ class OracleOps(DataAccessOps):
table: str,
sorted_links: list[tuple],
bank_id: str,
nil_entity_uuid: str,
exists_clause: str,
chunk_size: int = 5000,
) -> None:
@@ -152,16 +159,18 @@ class OracleOps(DataAccessOps):
to_ids = [lnk[1] for lnk in sorted_links]
types = [lnk[2] for lnk in sorted_links]
weights = [lnk[3] for lnk in sorted_links]
entity_ids = [lnk[4] for lnk in sorted_links]
await conn.executemany(
f"""
INSERT INTO {table}
(from_unit_id, to_unit_id, link_type, weight, bank_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (from_unit_id, to_unit_id, link_type)
(from_unit_id, to_unit_id, link_type, weight, entity_id, bank_id)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (from_unit_id, to_unit_id, link_type,
COALESCE(entity_id, '{nil_entity_uuid}'::uuid))
DO NOTHING
""",
[(from_ids[i], to_ids[i], types[i], weights[i], bank_id) for i in range(len(sorted_links))],
[(from_ids[i], to_ids[i], types[i], weights[i], entity_ids[i], bank_id) for i in range(len(sorted_links))],
)
async def bulk_insert_entities(
@@ -1329,13 +1338,14 @@ class OracleOps(DataAccessOps):
else:
rows = await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type = $1
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY created_at
SELECT o.operation_id, o.operation_type, o.task_payload, o.retry_count
FROM {table} o
WHERE o.status = 'pending'
AND o.task_payload IS NOT NULL
AND o.operation_type = $1
AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW())
AND {graph_maintenance_bank_serialization_sql(table, "o")}
ORDER BY o.created_at
LIMIT $2
FOR UPDATE SKIP LOCKED
""",
@@ -1350,18 +1360,21 @@ class OracleOps(DataAccessOps):
# --- Phase 2: claim from shared pool ---
remaining_shared = shared_limit
if remaining_shared > 0:
# 2a. Non-consolidation tasks
# 2a. Non-consolidation tasks. graph_maintenance stays in this
# created_at-ordered query — see graph_maintenance_bank_serialization_sql
# for why it is a predicate rather than a phase of its own.
if claimed_ids:
rows = await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type != 'consolidation'
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
AND operation_id != ALL($1::uuid[])
ORDER BY created_at
SELECT o.operation_id, o.operation_type, o.task_payload, o.retry_count
FROM {table} o
WHERE o.status = 'pending'
AND o.task_payload IS NOT NULL
AND o.operation_type != 'consolidation'
AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW())
AND o.operation_id != ALL($1::uuid[])
AND {graph_maintenance_bank_serialization_sql(table, "o")}
ORDER BY o.created_at
LIMIT $2
FOR UPDATE SKIP LOCKED
""",
@@ -1371,13 +1384,14 @@ class OracleOps(DataAccessOps):
else:
rows = await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type != 'consolidation'
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY created_at
SELECT o.operation_id, o.operation_type, o.task_payload, o.retry_count
FROM {table} o
WHERE o.status = 'pending'
AND o.task_payload IS NOT NULL
AND o.operation_type != 'consolidation'
AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW())
AND {graph_maintenance_bank_serialization_sql(table, "o")}
ORDER BY o.created_at
LIMIT $1
FOR UPDATE SKIP LOCKED
""",
@@ -4,10 +4,17 @@ Uses unnest(), LATERAL, DISTINCT ON, and native array operations for
efficient batch operations.
"""
import asyncio
from datetime import datetime
from .base import DatabaseConnection
from .ops import DataAccessOps, LinkExpansionRows, TagListingParts, UpdatedWindow
from .ops import (
DataAccessOps,
LinkExpansionRows,
TagListingParts,
UpdatedWindow,
graph_maintenance_bank_serialization_sql,
)
from .result import ResultRow
@@ -16,24 +23,34 @@ def pg_search_vector_expr(
*,
text_col: str = "text",
context_col: str = "context",
signals_col: str = "text_signals",
signals_col: str | None = "text_signals",
native_inline: bool = True,
) -> 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.
Single source of truth shared by ``memory_units`` (the batch insert over the
``input_data`` CTE columns and the curation-revert recompute) and
``mental_models`` (the knowledge-page writes), so the per-backend tokenization
can never drift between the two tables. 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.
The ``*_col`` arguments are the SQL for each text source (a column name or a
bind placeholder); pass ``signals_col=None`` for a two-column table like
``mental_models`` (name + content). Pass ``native_inline=False`` when the
table's native ``search_vector`` is a GENERATED column that populates itself
(``mental_models``) — writing it inline would fail; only vchord's plain
bm25vector column then needs an explicit value.
``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}, '')"
cols = [text_col, context_col] + ([signals_col] if signals_col is not None else [])
combined = " || ' ' || ".join(f"COALESCE({c}, '')" for c in cols)
if config.text_search_extension == "vchord":
return f"tokenize({combined}, 'llmlingua2')::bm25_catalog.bm25vector"
if config.text_search_extension == "native":
if config.text_search_extension == "native" and native_inline:
return f"to_tsvector('{config.text_search_extension_native_language}'::regconfig, {combined})"
return None
@@ -41,6 +58,23 @@ def pg_search_vector_expr(
class PostgreSQLOps(DataAccessOps):
"""PostgreSQL-specific data access operations using unnest and LATERAL."""
def __init__(self) -> None:
# Per-table serialization of per-bank vector-index DDL within this
# process. Concurrent index DDL on one relation deadlocks by design:
# DROP INDEX CONCURRENTLY holds ShareUpdateExclusive while it waits out
# every transaction whose snapshot could still see the index — including
# other sessions' index DDL queued on that same lock — so many banks
# deleted at once form a wait cycle Postgres resolves by killing one.
# A session advisory lock would serialize this across processes too, but
# advisory locks are banned here (poolers hand sessions around; see the
# Database Locking standard). In-process the asyncio lock removes the
# cycle outright; across processes the callers' retry-with-backoff
# absorbs the (now much rarer) collisions.
self._index_ddl_locks: dict[str, asyncio.Lock] = {}
def _index_ddl_lock(self, table: str) -> asyncio.Lock:
return self._index_ddl_locks.setdefault(table, asyncio.Lock())
@property
def uses_observation_sources_table(self) -> bool:
return False # PG uses native array ops on source_memory_ids
@@ -182,6 +216,7 @@ class PostgreSQLOps(DataAccessOps):
table: str,
sorted_links: list[tuple],
bank_id: str,
nil_entity_uuid: str,
exists_clause: str,
chunk_size: int = 5000,
) -> None:
@@ -206,6 +241,7 @@ class PostgreSQLOps(DataAccessOps):
to_ids = [lnk[1] for lnk in sorted_links]
types = [lnk[2] for lnk in sorted_links]
weights = [lnk[3] for lnk in sorted_links]
entity_ids = [lnk[4] for lnk in sorted_links]
for chunk_start in range(0, len(sorted_links), chunk_size):
chunk_end = min(chunk_start + chunk_size, len(sorted_links))
@@ -219,23 +255,25 @@ class PostgreSQLOps(DataAccessOps):
f"""
WITH locked AS (
SELECT id FROM {mu_table}
WHERE id = ANY($6::uuid[])
WHERE id = ANY($7::uuid[])
ORDER BY id
FOR KEY SHARE
)
INSERT INTO {table}
(from_unit_id, to_unit_id, link_type, weight, bank_id)
SELECT f, t, tp, w, $5
FROM unnest($1::uuid[], $2::uuid[], $3::text[], $4::float8[])
AS u(f, t, tp, w)
(from_unit_id, to_unit_id, link_type, weight, entity_id, bank_id)
SELECT f, t, tp, w, e, $6
FROM unnest($1::uuid[], $2::uuid[], $3::text[], $4::float8[], $5::uuid[])
AS u(f, t, tp, w, e)
WHERE f IN (SELECT id FROM locked) AND t IN (SELECT id FROM locked)
ON CONFLICT (from_unit_id, to_unit_id, link_type)
ON CONFLICT (from_unit_id, to_unit_id, link_type,
COALESCE(entity_id, '{nil_entity_uuid}'::uuid))
DO NOTHING
""",
chunk_from,
chunk_to,
types[chunk_start:chunk_end],
weights[chunk_start:chunk_end],
entity_ids[chunk_start:chunk_end],
bank_id,
referenced,
timeout=300,
@@ -853,14 +891,15 @@ class PostgreSQLOps(DataAccessOps):
fact_types: dict[str, str],
) -> None:
escaped = bank_id.replace("'", "''")
for ft, suffix in fact_types.items():
uid = str(internal_id).replace("-", "")[:16]
idx = f"idx_mu_emb_{suffix}_{uid}"
await conn.execute(
f"CREATE INDEX IF NOT EXISTS {idx} "
f"ON {table} {index_clause} "
f"WHERE fact_type = '{ft}' AND bank_id = '{escaped}'"
)
async with self._index_ddl_lock(table):
for ft, suffix in fact_types.items():
uid = str(internal_id).replace("-", "")[:16]
idx = f"idx_mu_emb_{suffix}_{uid}"
await conn.execute(
f"CREATE INDEX IF NOT EXISTS {idx} "
f"ON {table} {index_clause} "
f"WHERE fact_type = '{ft}' AND bank_id = '{escaped}'"
)
async def drop_bank_vector_indexes(
self,
@@ -875,10 +914,13 @@ class PostgreSQLOps(DataAccessOps):
# table; CONCURRENTLY does not conflict with DML. The caller
# (delete_bank) runs this on an autocommit connection after its delete
# transaction has committed — CONCURRENTLY cannot run inside a tx.
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 CONCURRENTLY IF EXISTS {schema}.{idx}")
# The lock key must match create_bank_vector_indexes', whose `table`
# is the fq name this reconstructs from `schema`.
async with self._index_ddl_lock(f"{schema}.memory_units"):
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 CONCURRENTLY IF EXISTS {schema}.{idx}")
def get_entity_resolution_strategy(self) -> str:
return "trigram"
@@ -1385,13 +1427,14 @@ class PostgreSQLOps(DataAccessOps):
else:
rows = await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type = $1
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY created_at
SELECT o.operation_id, o.operation_type, o.task_payload, o.retry_count
FROM {table} o
WHERE o.status = 'pending'
AND o.task_payload IS NOT NULL
AND o.operation_type = $1
AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW())
AND {graph_maintenance_bank_serialization_sql(table, "o")}
ORDER BY o.created_at
LIMIT $2
FOR UPDATE SKIP LOCKED
""",
@@ -1406,18 +1449,21 @@ class PostgreSQLOps(DataAccessOps):
# --- Phase 2: claim from shared pool ---
remaining_shared = shared_limit
if remaining_shared > 0:
# 2a. Non-consolidation tasks
# 2a. Non-consolidation tasks. graph_maintenance stays in this
# created_at-ordered query — see graph_maintenance_bank_serialization_sql
# for why it is a predicate rather than a phase of its own.
if claimed_ids:
rows = await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type != 'consolidation'
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
AND operation_id != ALL($1::uuid[])
ORDER BY created_at
SELECT o.operation_id, o.operation_type, o.task_payload, o.retry_count
FROM {table} o
WHERE o.status = 'pending'
AND o.task_payload IS NOT NULL
AND o.operation_type != 'consolidation'
AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW())
AND o.operation_id != ALL($1::uuid[])
AND {graph_maintenance_bank_serialization_sql(table, "o")}
ORDER BY o.created_at
LIMIT $2
FOR UPDATE SKIP LOCKED
""",
@@ -1427,13 +1473,14 @@ class PostgreSQLOps(DataAccessOps):
else:
rows = await conn.fetch(
f"""
SELECT operation_id, operation_type, task_payload, retry_count
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type != 'consolidation'
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY created_at
SELECT o.operation_id, o.operation_type, o.task_payload, o.retry_count
FROM {table} o
WHERE o.status = 'pending'
AND o.task_payload IS NOT NULL
AND o.operation_type != 'consolidation'
AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW())
AND {graph_maintenance_bank_serialization_sql(table, "o")}
ORDER BY o.created_at
LIMIT $1
FOR UPDATE SKIP LOCKED
""",
@@ -29,6 +29,12 @@ The worker dedupes on bank: a second job for the same bank is dropped
while one is pending. Once processing starts, a new job becomes the
*next* pending slot — so work enqueued during processing gets picked up
by the follow-up run.
That follow-up run is *deferred*, not parallel: ``claim_tasks`` will not claim a
graph_maintenance row for a bank that already has one in flight (#3230). Two
concurrent runs would do no extra work anyway — each is this same bank-wide
sweep — while convoying on each other's row locks and holding a worker slot
each.
"""
from __future__ import annotations
@@ -29,6 +29,7 @@ from ..config import (
ENV_REFLECT_LLM_MAX_CONCURRENT,
ENV_RETAIN_LLM_MAX_CONCURRENT,
)
from .cache_affinity import parse_cache_affinity
from .llm_interface import (
LLM_TOOL_CHOICE_AUTO,
LLMToolChoice,
@@ -270,6 +271,7 @@ _PROVIDERS_WITHOUT_API_KEY = frozenset(
"litellmrouter",
"bedrock",
"nous",
"xai-oauth",
}
)
@@ -310,6 +312,8 @@ def create_llm_provider(
gemini_service_tier: str | None = None,
timeout: float | None = None,
ollama_num_ctx: int | None = None,
cache_affinity: str | None = None,
structured_output_forced_tool: bool = False,
) -> Any: # Returns LLMInterface
"""
Factory function to create the appropriate LLM provider implementation.
@@ -333,9 +337,20 @@ def create_llm_provider(
for OpenAI/Anthropic vs ``max_output_tokens`` for Gemini).
default_headers: Custom headers passed to provider SDK clients (used by operators
routing through proxies / request-tracing middleware). Wired into the Anthropic
provider (SDK ``default_headers``) and the LiteLLM-backed providers — ``litellm``,
``litellmrouter`` and ``bedrock`` — as the LiteLLM ``extra_headers`` completion
kwarg; other providers may opt in as needed.
provider, the ``OpenAICompatibleLLM`` branch, ``fireworks``, ``nous`` and the
Responses API (SDK ``default_headers``), and into the LiteLLM-backed providers —
``litellm``, ``litellmrouter`` and ``bedrock`` — as the LiteLLM ``extra_headers``
completion kwarg; other providers may opt in as needed.
cache_affinity: Backend prompt-cache pinning mode, forwarded to the
``OpenAICompatibleLLM`` branch, ``fireworks`` and ``nous`` (all three share the
OpenAI-compatible wire format): "none" (default), "xai_conv_id",
"openai_prompt_cache_key", or "auto". Providers on other branches do their own
cache work or none at all. See ``engine/cache_affinity.py``.
structured_output_forced_tool: Ask the LiteLLM-backed providers (``litellm``,
``litellmrouter``, ``bedrock``) for structured output via a forced tool call
instead of ``response_format``. For backends that reject the response_format
route — see ``HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL``. Other
providers ignore it.
vertexai_project_id: Vertex AI project ID (for VertexAI provider).
vertexai_region: Vertex AI region (for VertexAI provider).
vertexai_credentials: Vertex AI credentials object (for VertexAI provider).
@@ -381,6 +396,7 @@ def create_llm_provider(
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
)
elif provider_lower == "claude-code":
@@ -447,6 +463,7 @@ def create_llm_provider(
extra_body=extra_body,
default_headers=default_headers,
timeout=timeout,
structured_output_forced_tool=structured_output_forced_tool,
)
elif provider_lower == "litellmrouter":
@@ -467,6 +484,7 @@ def create_llm_provider(
extra_body=extra_body,
default_headers=default_headers,
timeout=timeout,
structured_output_forced_tool=structured_output_forced_tool,
)
elif provider_lower == "bedrock":
@@ -482,6 +500,7 @@ def create_llm_provider(
default_headers=default_headers,
bedrock_service_tier=bedrock_service_tier,
timeout=timeout,
structured_output_forced_tool=structured_output_forced_tool,
)
elif provider_lower == "llamacpp":
@@ -513,12 +532,16 @@ def create_llm_provider(
model=model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
default_headers=default_headers,
cache_affinity=cache_affinity,
)
elif provider_lower == "nous":
# Nous Portal is OpenAI-compatible on the wire; NousLLM adds rotating
# inference:invoke JWT auth read natively from ~/.hermes/auth.json
# (no static api_key, no hermes_cli dependency — same shape as Codex).
# default_headers/cache_affinity ride NousLLM's **kwargs passthrough to
# OpenAICompatibleLLM.__init__ unchanged (see NousLLM.__init__).
from hindsight_api.engine.providers.nous_llm import NousLLM
return NousLLM(
@@ -528,6 +551,25 @@ def create_llm_provider(
model=model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
default_headers=default_headers,
cache_affinity=cache_affinity,
timeout=timeout,
)
elif provider_lower == "xai-oauth":
# SuperGrok subscription lane: api.x.ai spoken plainly, but the
# credential is a device-code OAuth grant with proactive/reactive
# refresh over a shared on-disk store, and xAI's 403 shapes need their
# own classification — neither fits the OpenAI SDK client, hence its
# own provider.
from hindsight_api.engine.providers.xai_oauth_llm import XaiOAuthLLM
return XaiOAuthLLM(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
timeout=timeout,
)
@@ -571,6 +613,8 @@ def create_llm_provider(
groq_service_tier=groq_service_tier,
openai_service_tier=openai_service_tier,
extra_body=extra_body,
default_headers=default_headers,
cache_affinity=cache_affinity,
ollama_num_ctx=ollama_num_ctx,
timeout=timeout,
)
@@ -610,6 +654,8 @@ class LLMProvider:
initial_backoff: float | None = None,
max_backoff: float | None = None,
ollama_num_ctx: int | None = None,
cache_affinity: str | None = None,
structured_output_forced_tool: bool = False,
):
"""
Initialize LLM provider.
@@ -631,6 +677,11 @@ class LLMProvider:
(OpenAI-compatible, Fireworks, Anthropic, Gemini/VertexAI, LiteLLM).
default_headers: Custom headers passed as ``default_headers`` to provider SDK clients.
Used by operators routing through proxies / request-tracing middleware.
cache_affinity: Backend prompt-cache pinning mode for the OpenAI-compatible and
Fireworks providers ("none", "xai_conv_id", "openai_prompt_cache_key",
"auto"). Validated here for every provider so a typo never fails silently;
providers on other factory branches ignore it. Used verbatim — callers
resolve the per-operation/global fallback.
litellmrouter_config: Provider-specific config for ``provider="litellmrouter"``.
JSON object passed verbatim to ``litellm.Router(**config)`` — see
https://docs.litellm.ai/docs/routing. Ignored unless ``provider == "litellmrouter"``.
@@ -651,6 +702,9 @@ class LLMProvider:
``max_retries``. ``None`` keeps each method's own fallback.
max_backoff: Default maximum retry backoff (seconds), same resolution as
``max_retries``. ``None`` keeps each method's own fallback.
structured_output_forced_tool: Structured output via a forced tool call
instead of ``response_format``, for the LiteLLM-backed providers - from
config (``HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL``).
This constructor uses every argument as passed and does not read global
``HindsightConfig``: resolving the server-level default for a ``None`` argument is the
@@ -679,6 +733,9 @@ class LLMProvider:
self.openai_service_tier = openai_service_tier
self.bedrock_service_tier = bedrock_service_tier
self.gemini_service_tier = gemini_service_tier
# Structured-output transport for the LiteLLM-backed providers. Used verbatim —
# the caller resolves the server-level default, like the fields above.
self.structured_output_forced_tool = structured_output_forced_tool
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
@@ -693,6 +750,11 @@ class LLMProvider:
# Used verbatim — callers resolve the global fallback (see _member_to_llm /
# the per-op builds in MemoryEngine, and LLMProvider.from_env).
self.default_headers = default_headers
# Backend prompt-cache pinning mode. Validated here rather than only at the
# provider so a typo fails for every provider, not just the ones that act on
# it — the setting has no visible effect in the response, so a silent
# fallback to "none" would be indistinguishable from it working.
self.cache_affinity = parse_cache_affinity(cache_affinity).value
# Validate provider
valid_providers = [
@@ -723,6 +785,7 @@ class LLMProvider:
"atlas",
"fireworks",
"nous",
"xai-oauth",
]
if self.provider not in valid_providers:
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
@@ -827,6 +890,8 @@ class LLMProvider:
litellmrouter_config=router_config,
ollama_num_ctx=self.ollama_num_ctx,
timeout=self.timeout,
cache_affinity=self.cache_affinity,
structured_output_forced_tool=self.structured_output_forced_tool,
)
# Backward compatibility: Keep mock provider properties
@@ -1366,15 +1431,18 @@ class LLMProvider:
# does so without building the full HindsightConfig, keeping from_env() a
# lightweight env-only loader (see test_llm_provider_from_env_keeps_lightweight_loader).
from ..config import (
DEFAULT_LLM_CACHE_AFFINITY,
DEFAULT_LLM_GROQ_SERVICE_TIER,
DEFAULT_LLM_OPENAI_SERVICE_TIER,
DEFAULT_LLM_PROMPT_CACHE_ENABLED,
DEFAULT_LLM_PROVIDER,
DEFAULT_LLM_REASONING_EFFORT,
DEFAULT_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
DEFAULT_LLM_TIMEOUT,
ENV_LLM_API_KEY,
ENV_LLM_BASE_URL,
ENV_LLM_BEDROCK_SERVICE_TIER,
ENV_LLM_CACHE_AFFINITY,
ENV_LLM_DEFAULT_HEADERS,
ENV_LLM_EXTRA_BODY,
ENV_LLM_GEMINI_SAFETY_SETTINGS,
@@ -1387,11 +1455,13 @@ class LLMProvider:
ENV_LLM_PROMPT_CACHE_ENABLED,
ENV_LLM_PROVIDER,
ENV_LLM_REASONING_EFFORT,
ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
ENV_LLM_TIMEOUT,
ENV_LLM_VERTEXAI_PROJECT_ID,
ENV_LLM_VERTEXAI_REGION,
ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY,
_get_default_model_for_provider,
_parse_boolean_env,
_parse_llm_router_config,
_parse_optional_positive_int,
parse_gemini_service_tier,
@@ -1411,6 +1481,9 @@ class LLMProvider:
model = os.getenv(ENV_LLM_MODEL) or _get_default_model_for_provider(provider)
extra_body = json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null"))
default_headers = json.loads(os.getenv(ENV_LLM_DEFAULT_HEADERS, "null"))
# Same default as HindsightConfig.from_env: this entry point must not
# resolve to a different mode than the engine's own config path.
cache_affinity = os.getenv(ENV_LLM_CACHE_AFFINITY, DEFAULT_LLM_CACHE_AFFINITY) or None
prompt_cache_enabled = os.getenv(
ENV_LLM_PROMPT_CACHE_ENABLED, str(DEFAULT_LLM_PROMPT_CACHE_ENABLED)
).lower() in (
@@ -1428,6 +1501,7 @@ class LLMProvider:
reasoning_effort=os.getenv(ENV_LLM_REASONING_EFFORT, DEFAULT_LLM_REASONING_EFFORT),
extra_body=extra_body,
default_headers=default_headers,
cache_affinity=cache_affinity,
groq_service_tier=os.getenv(ENV_LLM_GROQ_SERVICE_TIER, DEFAULT_LLM_GROQ_SERVICE_TIER),
openai_service_tier=os.getenv(ENV_LLM_OPENAI_SERVICE_TIER, DEFAULT_LLM_OPENAI_SERVICE_TIER),
bedrock_service_tier=os.getenv(ENV_LLM_BEDROCK_SERVICE_TIER) or None,
@@ -1444,6 +1518,10 @@ class LLMProvider:
vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION) or None,
vertexai_service_account_key=os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY) or None,
timeout=float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT))),
structured_output_forced_tool=_parse_boolean_env(
ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
DEFAULT_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
),
)
@@ -138,7 +138,12 @@ class MaintenanceLoop:
@staticmethod
def _cross_store_recovery_enabled() -> bool:
"""True when the memories store keeps memories outside SQL and therefore has
cross-store write-group txns a crashed writer could leave undecided."""
cross-store write-group txns a crashed writer could leave undecided.
Deliberately reads the PROCESS-LEVEL class attribute, not the per-bank
``writes_memory_rows_in_sql_for(bank_id)`` — this only decides whether the recovery LOOP
needs to run at all. A store that routes some banks outside SQL keeps the class attribute
False so the loop runs, then ``recover_pending_txns`` is bank-scoped inside it."""
try:
from .memories import get_memories
@@ -282,6 +287,9 @@ class MaintenanceLoop:
try:
table = fq_table_explicit("async_operations", schema)
async with acquire_with_retry(backend, max_retries=1) as conn:
# Delete export archives owned by rows about to be pruned first,
# so the file-storage blobs don't outlive their operation row.
await engine.purge_expired_export_archives(conn, table, cutoff)
async with conn.transaction():
deleted = await backend.ops.prune_terminal_operations(
conn, table, cutoff, batch_size=cfg.operation_cleanup_batch_size
@@ -394,6 +394,20 @@ class MemoriesExtension(Extension, ABC):
#: the inline SQL. Cold, never-searched, key-based — see docs/documents-chunks.md.
owns_document_store: bool = False
def writes_memory_rows_in_sql_for(self, bank_id: str) -> bool:
"""Per-bank form of :attr:`writes_memory_rows_in_sql`. Defaults to the class attribute, so a
single-store extension needs no override. A store that keeps different banks in different
backends (some in SQL, some not) overrides this to answer PER BANK; every *bank-scoped* call
site consults this instead of the class attribute, so mixed banks each take the correct path.
(The few process-level gates — e.g. "is cross-store txn recovery relevant at all" — keep
reading the class attribute.)"""
return self.writes_memory_rows_in_sql
def owns_document_store_for(self, bank_id: str) -> bool:
"""Per-bank form of :attr:`owns_document_store`. Defaults to the class attribute; a store
that keeps some banks in a separate backend overrides it. See :meth:`writes_memory_rows_in_sql_for`."""
return self.owns_document_store
# ------------------------------------------------------------------ lifecycle
async def initialize(self) -> None:
@@ -486,9 +486,9 @@ async def enqueue_relink_victims(
affected_str_set = {str(uid) for uid in affected_uuids}
# Find units (other than the affected ones) that have an outgoing
# temporal/semantic link pointing at an affected unit. Only those two link
# types are relinked by graph maintenance; entity edges are not stored in
# memory_links (they're derived from unit_entities), so nothing else applies.
# 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(
f"""
SELECT DISTINCT from_unit_id
@@ -531,9 +531,14 @@ async def relink_pass(
) -> dict:
"""Drain ``graph_maintenance_queue`` for ``bank_id``, topping up lost links.
Per-iteration loop: claim → top up → commit. We rely on submit-time
dedup to keep at most one job per bank running, so no need for
SKIP LOCKED.
Per-iteration loop: claim → top up → commit. We rely on at most one job per
bank running, so no need for SKIP LOCKED. Submit-time dedup alone does NOT
give that — it only inspects 'pending' rows — so the guarantee comes from
``claim_tasks``, which refuses to claim a graph_maintenance row for a bank
that already has one in flight (``graph_maintenance_bank_serialization_sql``,
#3230). Without it these claims convoy: they lock queue rows ``FOR UPDATE``
with no ``SKIP LOCKED``, so a second run blocks on the first while holding a
worker slot.
Takes ``backend`` rather than a connection because the loop spans several
transactions — one per claimed batch, plus a separate connection for the ANN
@@ -662,7 +667,7 @@ async def _relink_batch(
if time_diff_h > 24:
continue
weight = max(0.3, 1.0 - (time_diff_h / 24))
new_links.append((row["from_id"], str(row["id"]), "temporal", weight))
new_links.append((row["from_id"], str(row["id"]), "temporal", weight, None))
# --- Semantic top-up ---
# ANN must run on its own connection: it opens a nested transaction with
File diff suppressed because it is too large Load Diff
@@ -127,6 +127,7 @@ class CodexLLM(LLMInterface):
base_url: str,
model: str,
reasoning_effort: str = "low",
extra_body: dict[str, Any] | None = None,
**kwargs: Any,
):
"""Initialize Codex LLM provider."""
@@ -177,6 +178,7 @@ class CodexLLM(LLMInterface):
# 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)
self._extra_body = dict(extra_body or {})
# HTTP client for SSE streaming
self._client = httpx.AsyncClient(timeout=120.0)
@@ -457,6 +459,7 @@ class CodexLLM(LLMInterface):
"include": ["reasoning.encrypted_content"],
"prompt_cache_key": str(uuid.uuid4()),
}
payload.update(self._extra_body)
if use_forced_tool and schema is not None:
# Single function tool whose parameters ARE the response schema;
@@ -845,6 +848,7 @@ class CodexLLM(LLMInterface):
"include": ["reasoning.encrypted_content"],
"prompt_cache_key": str(uuid.uuid4()),
}
payload.update(self._extra_body)
headers = self._build_request_headers()
@@ -576,23 +576,14 @@ 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
# such failure drop the cache, invalidate it so later operations
# recreate it, and retry THIS call inline with the prefix inlined.
# Caching must never break a request.
# Caching must never break a request. Handled before the 400
# fail-fast below so a recoverable cache-400 isn't mistaken for a
# deterministic rejection.
if cache_active and e.code == 400:
logger.warning(f"Gemini cached call failed (400); retrying uncached. Reason: {str(e)}")
if self._cache_manager is not None and cached_prefix is not None:
@@ -601,8 +592,31 @@ class GeminiLLM(LLMInterface):
generation_config = _build_generation_config(cache_active)
continue
# Retry on retryable errors (rate limits, server errors, client errors)
if e.code in (400, 429, 500, 502, 503, 504) or (e.code and e.code >= 500):
# Diagnostic dump of the exact request behind any 4xx. Forced on for a
# non-recoverable 400 (see below) so its content-free structural profile
# is always in the log on first occurrence; other 4xx dump only under
# the opt-in HINDSIGHT_API_LLM_DEBUG_DUMP_4XX flag.
dump_request_on_4xx(
scope=scope,
provider=self.provider,
model=self.model,
err=e,
request=generation_config,
messages=gemini_contents,
force=e.code == 400,
)
# HTTP 400 INVALID_ARGUMENT is a deterministic client-side rejection
# (malformed schema, oversized prompt section, bad generation param).
# Now that the recoverable cache-400 is ruled out, retrying it — and
# the batch retry ladder above — just repeats an identical rejected
# call, so fail fast instead of burning the retry budget (#3256).
if e.code == 400:
logger.error(f"Gemini rejected request (HTTP 400 INVALID_ARGUMENT), not retrying: {str(e)}")
raise
# Retry on retryable errors (rate limits, server errors)
if e.code in (429, 500, 502, 503, 504) or (e.code and e.code >= 500):
last_exception = e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
@@ -885,21 +899,12 @@ 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
# with the prefix + tools re-sent. Caching must never break a call.
# Handled before the 400 fail-fast below so a recoverable cache-400
# isn't mistaken for a deterministic rejection.
if cache_active and e.code == 400:
logger.warning(f"Gemini cached tool call failed (400); retrying uncached. Reason: {str(e)}")
if self._cache_manager is not None and cached_prefix is not None:
@@ -908,6 +913,28 @@ class GeminiLLM(LLMInterface):
config = _build_tools_config(cache_active)
continue
# Diagnostic dump of the exact request behind any 4xx. Forced on for a
# non-recoverable 400 so its content-free structural profile is always
# in the log on first occurrence; other 4xx dump only under the opt-in
# HINDSIGHT_API_LLM_DEBUG_DUMP_4XX flag.
dump_request_on_4xx(
scope=scope,
provider=self.provider,
model=self.model,
err=e,
request=config,
messages=active_contents,
force=e.code == 400,
)
# HTTP 400 INVALID_ARGUMENT is a deterministic client-side rejection;
# now that the recoverable cache-400 is ruled out, retrying it — and
# the batch retry ladder above — just repeats an identical rejected
# call, so fail fast instead of burning the retry budget (#3256).
if e.code == 400:
logger.error(f"Gemini rejected tool request (HTTP 400 INVALID_ARGUMENT), not retrying: {str(e)}")
raise
# Retry on retryable errors
last_exception = e
if attempt < max_retries:
@@ -40,6 +40,10 @@ from hindsight_api.worker.stage import set_stage
logger = logging.getLogger(__name__)
# Name of the single tool used when structured output is routed through a forced
# tool call instead of ``response_format`` (see ``structured_output_forced_tool``).
_STRUCTURED_TOOL_NAME = "structured_response"
def _usage_from_litellm_response(response: Any) -> LLMResponseUsage:
"""Extract prompt/completion/cached token counts from a LiteLLM (OpenAI-shaped) usage block."""
@@ -57,6 +61,22 @@ def _usage_from_litellm_response(response: Any) -> LLMResponseUsage:
)
def _forced_tool_arguments(message: Any) -> str | None:
"""Return the structured-output tool call's arguments as a JSON string.
``None`` when the model answered with plain text instead — some gateways drop
``tool_choice`` — so the caller can fall back to parsing the message content.
"""
for tool_call in message.tool_calls or []:
if tool_call.function.name != _STRUCTURED_TOOL_NAME:
continue
# LiteLLM normalizes to the OpenAI shape (a JSON string), but some
# providers hand back an already-decoded object.
arguments = tool_call.function.arguments
return arguments if isinstance(arguments, str) else json.dumps(arguments)
return None
class LiteLLMLLM(LLMInterface):
"""
LLM provider using the LiteLLM SDK for universal model support.
@@ -82,6 +102,7 @@ class LiteLLMLLM(LLMInterface):
extra_body: dict[str, Any] | None = None,
bedrock_service_tier: str | None = None,
default_headers: dict[str, Any] | None = None,
structured_output_forced_tool: bool = False,
**kwargs: Any,
):
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
@@ -103,6 +124,12 @@ class LiteLLMLLM(LLMInterface):
# copy is handed to each call below to avoid cross-request contamination.
self._default_headers: dict[str, Any] = dict(default_headers or {})
self.bedrock_service_tier = bedrock_service_tier
# Ask for structured output via a single forced tool call instead of
# ``response_format``. Opt-in (HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL)
# for backends that reject the response_format route — Bedrock Claude's
# Converse layer refuses the translated ``outputConfig`` in some regions but
# accepts the same schema as a tool (#3300).
self.structured_output_forced_tool = structured_output_forced_tool
try:
import litellm
@@ -243,16 +270,38 @@ class LiteLLMLLM(LLMInterface):
call_kwargs = self._build_common_kwargs(messages, max_completion_tokens, temperature)
# Add JSON schema response format if provided
use_forced_tool = False
if response_format is not None and hasattr(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": {
"name": response_format.__name__ if hasattr(response_format, "__name__") else "response",
"schema": schema,
"strict": strict_schema,
},
}
schema_name = response_format.__name__ if hasattr(response_format, "__name__") else "response"
if self.structured_output_forced_tool:
# The schema travels as the tool's parameters and the model is forced
# to call it; the arguments are substituted for the message content
# below, so the parse/validate, retry and usage paths are unchanged.
use_forced_tool = True
call_kwargs["tools"] = [
{
"type": "function",
"function": {
"name": _STRUCTURED_TOOL_NAME,
"description": f"Return the structured response ({schema_name}).",
"parameters": schema,
},
}
]
call_kwargs["tool_choice"] = {
"type": "function",
"function": {"name": _STRUCTURED_TOOL_NAME},
}
else:
call_kwargs["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": schema_name,
"schema": schema,
"strict": strict_schema,
},
}
last_exception = None
@@ -269,10 +318,19 @@ class LiteLLMLLM(LLMInterface):
# these tokens (#2387).
stash_response_usage(_usage_from_litellm_response(response))
content = response.choices[0].message.content or ""
message = response.choices[0].message
content = message.content or ""
finish_reason = response.choices[0].finish_reason
model_name = self._resolve_completion_model(response)
if use_forced_tool:
# Forced tool call: its arguments ARE the structured response.
# Absent (a gateway that drops tool_choice) -> keep the text
# content so the existing parse path still has a chance.
forced_arguments = _forced_tool_arguments(message)
if forced_arguments is not None:
content = forced_arguments
# Check for length-limited output
if finish_reason == "length":
raise OutputTooLongError("LiteLLM response was truncated due to token limit")
@@ -135,25 +135,41 @@ def dump_request_on_4xx(
err: Any,
request: Any = None,
messages: Any = None,
force: bool = False,
) -> 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
No-op unless ``err`` carries a 4xx status AND either the
``HINDSIGHT_API_LLM_DEBUG_DUMP_4XX`` flag is truthy or ``force`` is set.
``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``).
``force`` is for deterministic rejections a retry can't fix (e.g. a 400
``INVALID_ARGUMENT``): the structural profile — request config, per-message
sizes — is logged on the first (and only) failure so an otherwise-opaque
black box is diagnosable in production without flipping a flag and
reproducing (#3256). Message *previews* stay gated behind the opt-in flag, so
the forced structural dump never spills user content — only per-part sizes.
"""
if not _enabled():
enabled = _enabled()
if not (enabled or force):
return
code = status_code_of(err)
if code is None or not (400 <= code < 500):
return
# Previews carry user content, so they ride only on the explicit opt-in; a
# forced dump logs the structural profile (config + per-part sizes) alone.
include_previews = enabled
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]})
entry: dict[str, Any] = {"role": m.role, "chars": len(m.text)}
if include_previews:
entry["preview"] = m.text[:_PREVIEW_CHARS]
summary.append(entry)
logger.error(
"[LLM_4XX_DUMP] provider=%s model=%s scope=%s code=%s err=%s config=%s contents=%s",
provider,
@@ -37,6 +37,12 @@ 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.cache_affinity import (
CacheAffinityMode,
apply_cache_affinity,
parse_cache_affinity,
resolve_cache_affinity,
)
from hindsight_api.engine.llm_interface import (
LLM_TOOL_CHOICE_AUTO,
LLMInterface,
@@ -531,6 +537,8 @@ class OpenAICompatibleLLM(LLMInterface):
groq_service_tier: str | None = None,
extra_body: dict[str, Any] | None = None,
*,
default_headers: dict[str, str] | None = None,
cache_affinity: str | None = None,
ollama_num_ctx: int | None = None,
**kwargs: Any,
):
@@ -549,6 +557,11 @@ class OpenAICompatibleLLM(LLMInterface):
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.
default_headers: Custom headers passed to the AsyncOpenAI client (proxies,
request-tracing middleware). None sends no extra headers.
cache_affinity: Backend prompt-cache pinning mode — "none" (default),
"xai_conv_id", "openai_prompt_cache_key", or "auto" (resolved once here
from the provider + base-URL host). See ``engine/cache_affinity.py``.
ollama_num_ctx: Native Ollama context window override. None lets Ollama use
the model/server default.
**kwargs: Additional provider-specific parameters.
@@ -643,8 +656,18 @@ class OpenAICompatibleLLM(LLMInterface):
# Get timeout config
self.timeout = timeout or float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT)))
# Backend prompt-cache pinning. "auto" is resolved ONCE here rather than
# per call: base_url is immutable after construction, so the answer can
# never change, and resolving per call would re-parse the URL on every
# request. Invalid values raise here so a typo fails at startup.
self._cache_affinity: CacheAffinityMode = resolve_cache_affinity(
parse_cache_affinity(cache_affinity), self.provider, self.base_url
)
# Create OpenAI client — extract query params from base_url (e.g. Azure api-version)
client_kwargs: dict[str, Any] = {"api_key": self.api_key, "max_retries": 0}
if default_headers:
client_kwargs["default_headers"] = default_headers
if self.base_url:
parsed = urlparse(self.base_url)
if parsed.query:
@@ -663,6 +686,10 @@ class OpenAICompatibleLLM(LLMInterface):
f"OpenAI-compatible client initialized: provider={self.provider}, model={self.model}, "
f"base_url={self.base_url or 'default'}"
)
logger.debug(
f"Cache affinity resolved: provider={self.provider}, base_url={self.base_url or 'default'}, "
f"mode={self._cache_affinity.value}"
)
def _drops_tool_choice_required(self) -> bool:
"""Whether this endpoint silently ignores ``tool_choice="required"``.
@@ -898,6 +925,14 @@ class OpenAICompatibleLLM(LLMInterface):
call_params["response_format"] = {"type": "json_object"}
apply_bank_attribution(call_params)
# Cache pinning, alongside the other identity injection above and, like
# call_params itself, built ONCE before the retry loop so every attempt
# carries it. Note the hash-point: when no trace context is bound the id
# falls back to hashing the first message, and the soft-schema branch
# above has already appended the response schema to it. That is
# deterministic (the schema text is fixed per response_format), so the id
# stays stable across the calls of one run.
apply_cache_affinity(call_params, self._cache_affinity)
last_exception = None
@@ -1281,6 +1316,7 @@ class OpenAICompatibleLLM(LLMInterface):
call_params["extra_body"] = extra_body
apply_bank_attribution(call_params)
apply_cache_affinity(call_params, self._cache_affinity)
last_exception = None
@@ -1463,7 +1499,9 @@ class OpenAICompatibleLLM(LLMInterface):
"model": self.model,
"messages": messages,
"stream": False,
"think": False, # Disable thinking for reasoning models (qwen3.5, etc.)
# Disable thinking by default (qwen3.5, etc.). Override via
# extra_body, e.g. {"think": "low"} for gpt-oss models (see #3246).
"think": False,
}
# Add schema as format parameter for structured output
@@ -1480,6 +1518,16 @@ class OpenAICompatibleLLM(LLMInterface):
options["num_predict"] = max_completion_tokens
if temperature is not None:
options["temperature"] = temperature
# Merge configured extra_body into the native payload. Ollama's native
# /api/chat body has two tiers, unlike the OpenAI-compatible endpoint
# where the SDK flattens everything to top-level: native top-level
# fields (think, keep_alive, ...) pass through directly, while an
# "options" sub-dict merges into Ollama's generation options
# (seed, top_p, num_ctx, ...). User values win over the defaults above.
extra_body = dict(self._config_extra_body)
options.update(extra_body.pop("options", {}))
payload.update(extra_body)
payload["options"] = options
last_exception = None
@@ -0,0 +1,923 @@
"""
xAI OAuth credential manager for the ``xai-oauth`` subscription provider.
Serves a consumer SuperGrok subscription flat-rate, no API key by holding a
user-consented OAuth grant obtained from xAI's own OIDC issuer at
``https://auth.x.ai`` through the RFC 8628 device-code flow, and refreshing it
with a plain ``grant_type=refresh_token`` POST. For API-key access to the same
``api.x.ai`` endpoint, use ``provider: openai`` with a base URL; this module is
the subscription lane's credential half.
Relationship to the other subscription providers
------------------------------------------------
``codex_auth.py`` and ``nous_auth.py`` read a credential file that a vendor CLI
created and keep it fresh. This module follows their store conventions one
JSON file, ``0600``, temp-file + ``os.replace`` rewrite, a per-store
``fcntl.flock`` advisory lock but owns the login itself, because it must never
read or write the Grok CLI's ``~/.grok/auth.json``. The login is therefore an
explicit, interactive entrypoint (``python -m
hindsight_api.engine.providers.xai_oauth_auth login``) and nothing on the
request path can reach it.
Store
-----
``~/.hindsight/xai_oauth.json`` by default (the directory ``daemon.py`` and
``llamacpp_llm.py`` already use), overridable with
``HINDSIGHT_API_XAI_OAUTH_TOKEN_PATH``. It holds the access token, the refresh
token, ``expires_at``, ``obtained_at``, the granted scope, and the discovered
``token_endpoint``.
xAI issues a **new refresh token with every refresh** and retires the old one,
so the store is not a read-only credential drop: whichever process refreshes
must be able to write back, and every process serving this provider must read
the same file rather than its own copy of one login's output.
Multi-instance safety
---------------------
Refresh state lives in the store, not on the manager: several provider
instances (one per configured lane) share one credential. Each refresh re-reads
the store after taking the lock and skips when a sibling's refresh already
landed, and the anti-spin minimum gap is measured from the store's
``obtained_at`` rather than an in-memory clock.
Logging
-------
Token values, refresh tokens, user codes and Authorization headers are never
passed to the logger at any level; log lines carry byte counts, expiry
timestamps, scope names and status codes only.
"""
from __future__ import annotations
import argparse
import contextlib
import json
import logging
import os
import sys
import tempfile
import threading
import time
from collections.abc import Callable, Iterator
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import httpx
try:
import fcntl
except ImportError: # pragma: no cover - Windows
fcntl = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
__all__ = [
"DEFAULT_CLIENT_ID",
"DEFAULT_MIN_REFRESH_GAP_SECONDS",
"DEFAULT_REFRESH_SKEW_SECONDS",
"DEFAULT_REFRESH_TIMEOUT_SECONDS",
"DEFAULT_SCOPE",
"DEVICE_CODE_GRANT_TYPE",
"ENV_CLIENT_ID",
"ENV_REFRESH_SKEW_SECONDS",
"ENV_REFRESH_TIMEOUT_SECONDS",
"ENV_SCOPE",
"ENV_TOKEN_PATH",
"LOGIN_COMMAND",
"StoredCredential",
"XaiOAuthDiscoveryError",
"XaiOAuthError",
"XaiOAuthLoginRequiredError",
"XaiOAuthManager",
"XaiOAuthRefreshError",
"default_token_path",
"device_code_login",
"discover_endpoints",
"read_credential",
"request_device_code",
"poll_device_token",
"write_credential",
]
# ---------------------------------------------------------------------------
# Environment surface
# ---------------------------------------------------------------------------
#: Relocate the token store. The read and the write both follow it, so this is
#: a full relocation rather than a read-side override.
ENV_TOKEN_PATH = "HINDSIGHT_API_XAI_OAUTH_TOKEN_PATH"
#: Override the OAuth client id used for both login and refresh.
ENV_CLIENT_ID = "HINDSIGHT_API_XAI_OAUTH_CLIENT_ID"
#: Override the scope string requested at login.
ENV_SCOPE = "HINDSIGHT_API_XAI_OAUTH_SCOPE"
#: Per-HTTP-call timeout for discovery, device-code and refresh requests.
ENV_REFRESH_TIMEOUT_SECONDS = "HINDSIGHT_API_XAI_OAUTH_REFRESH_TIMEOUT_SECONDS"
#: How long before ``expires_at`` a token counts as due for refresh.
ENV_REFRESH_SKEW_SECONDS = "HINDSIGHT_API_XAI_OAUTH_REFRESH_SKEW_SECONDS"
# ---------------------------------------------------------------------------
# Vendor constants
# ---------------------------------------------------------------------------
XAI_OAUTH_ISSUER = "https://auth.x.ai"
XAI_OAUTH_DISCOVERY_URL = f"{XAI_OAUTH_ISSUER}/.well-known/openid-configuration"
#: Fallback device-authorization endpoint, used only when the discovery
#: document omits ``device_authorization_endpoint``.
XAI_OAUTH_DEVICE_CODE_URL = f"{XAI_OAUTH_ISSUER}/oauth2/device/code"
#: Public OAuth client id published in xAI's Apache-2.0 Grok CLI sources
#: (``crates/codegen/xai-grok-shell/src/auth/config.rs`` in
#: github.com/xai-org/grok-build). Not a secret: a device-code public client has
#: no client secret by construction.
DEFAULT_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
#: Scope string the vendor's own device-code login requests.
DEFAULT_SCOPE = "openid profile email offline_access grok-cli:access api:access"
DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
#: Refresh this many seconds before ``expires_at``. 60s matches
#: ``codex_auth.py``'s skew; deployments that touch the provider rarely (a cron
#: or gateway shape) can widen it with ``ENV_REFRESH_SKEW_SECONDS``.
DEFAULT_REFRESH_SKEW_SECONDS = 60.0
#: Hard per-request timeout for every call this module makes.
DEFAULT_REFRESH_TIMEOUT_SECONDS = 20.0
#: Anti-spin floor: a credential obtained less than this many seconds ago is
#: not refreshed again. Measured from the store's ``obtained_at``.
DEFAULT_MIN_REFRESH_GAP_SECONDS = 30.0
#: Wait this long for the cross-process store lock before giving up.
AUTH_LOCK_TIMEOUT_SECONDS = 20.0
#: OAuth token-endpoint statuses that mean the grant itself is dead.
#:
#: RFC 6749 section 5.2 answers a spent or revoked ``refresh_token`` with 400
#: ``invalid_grant``, and 401 covers client authentication. A 403 is not a
#: token-endpoint error shape at all — it is what an edge or policy layer in
#: front of the issuer returns — so it stays off this set: quarantining on one
#: would trade a transient upstream refusal for a mandatory interactive
#: re-login, which is the same reasoning ``xai_oauth_llm`` applies to a 403
#: from the API side.
TERMINAL_REFRESH_STATUSES = frozenset({400, 401})
LOGIN_COMMAND = "python -m hindsight_api.engine.providers.xai_oauth_auth login"
_STORE_LOCKS_GUARD = threading.Lock()
_STORE_LOCKS: dict[Path, threading.Lock] = {}
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
class XaiOAuthError(RuntimeError):
"""Base class for every credential-side failure in this module."""
class XaiOAuthLoginRequiredError(XaiOAuthError):
"""Raised when only an interactive login can restore service.
Carries the exact command to run. Nothing on the request path ever starts
the device-code flow itself, so this is the terminus of the unattended
path rather than a prompt.
"""
class XaiOAuthDiscoveryError(XaiOAuthError):
"""Raised when OIDC discovery fails or returns an endpoint off the xAI origin."""
class XaiOAuthRefreshError(XaiOAuthError):
"""Raised when a refresh fails in a way a later attempt might survive."""
# ---------------------------------------------------------------------------
# Store
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class StoredCredential:
"""One credential as it exists on disk.
``expires_at`` is ``None`` when the store never recorded one. Such a
credential is treated as due for refresh, never as valid.
"""
access_token: str
refresh_token: str
expires_at: float | None
obtained_at: float
scope: str
token_endpoint: str
def seconds_left(self, now: float | None = None) -> float:
"""Seconds until expiry, or ``0.0`` when the store recorded no expiry."""
if self.expires_at is None:
return 0.0
return self.expires_at - (time.time() if now is None else now)
def default_token_path() -> Path:
"""Return the token-store path, honoring ``ENV_TOKEN_PATH``.
Resolved on each call rather than cached at import, so the environment is
read at the point of use.
"""
configured = os.environ.get(ENV_TOKEN_PATH, "").strip()
if configured:
return Path(configured).expanduser()
return Path.home() / ".hindsight" / "xai_oauth.json"
def _path_scoped_lock(token_path: Path) -> threading.Lock:
"""Return the process-wide lock for one store path."""
key = token_path.expanduser().resolve(strict=False)
with _STORE_LOCKS_GUARD:
lock = _STORE_LOCKS.get(key)
if lock is None:
lock = threading.Lock()
_STORE_LOCKS[key] = lock
return lock
@contextlib.contextmanager
def token_store_lock(token_path: Path, timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS) -> Iterator[None]:
"""Hold the advisory lock for one token store.
An in-process lock keyed by the resolved path serialises this process's own
managers; a ``fcntl.flock`` on ``<store>.lock`` extends that across
processes. Where ``fcntl`` is unavailable (Windows) only the in-process lock
applies the same degradation ``codex_auth.py`` and ``nous_auth.py`` take.
"""
with _path_scoped_lock(token_path):
if fcntl is None: # pragma: no cover - Windows
logger.debug("fcntl unavailable; xai-oauth refresh proceeds without a cross-process lock.")
yield
return
lock_path = token_path.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 xai-oauth token store lock") from None
time.sleep(0.05)
try:
yield
finally:
with contextlib.suppress(OSError):
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
def _coerce_float(value: Any) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def read_credential(token_path: Path) -> StoredCredential:
"""Load the credential from ``token_path``.
Raises
------
XaiOAuthLoginRequiredError:
When the file is missing, unreadable, malformed, or carries no
``refresh_token`` every shape from which only a login recovers.
"""
if not token_path.exists():
raise XaiOAuthLoginRequiredError(
f"No xai-oauth credential found at {token_path}. "
f"Run the xai-oauth login on the host that owns the token store: {LOGIN_COMMAND}"
)
try:
with open(token_path) as handle:
data = json.load(handle)
except (OSError, json.JSONDecodeError) as exc:
raise XaiOAuthLoginRequiredError(
f"The xai-oauth credential at {token_path} is unreadable ({type(exc).__name__}). "
f"Run the xai-oauth login on the host that owns the token store: {LOGIN_COMMAND}"
) from exc
tokens = data.get("tokens") if isinstance(data, dict) else None
tokens = tokens if isinstance(tokens, dict) else {}
access_token = tokens.get("access_token")
refresh_token = tokens.get("refresh_token")
if not isinstance(refresh_token, str) or not refresh_token:
raise XaiOAuthLoginRequiredError(
f"The xai-oauth credential at {token_path} has no refresh_token. "
f"Run the xai-oauth login on the host that owns the token store: {LOGIN_COMMAND}"
)
return StoredCredential(
access_token=access_token if isinstance(access_token, str) else "",
refresh_token=refresh_token,
expires_at=_coerce_float(data.get("expires_at")),
obtained_at=_coerce_float(data.get("obtained_at")) or 0.0,
scope=str(data.get("scope") or ""),
token_endpoint=str(data.get("token_endpoint") or ""),
)
def write_credential(token_path: Path, credential: StoredCredential) -> None:
"""Persist ``credential`` to ``token_path`` as one atomic replacement.
The payload is written to a sibling temp file, fsynced, chmod-ed ``0600``
where the platform supports it, and moved into place with ``os.replace``
so a reader never observes a partially written store.
"""
payload = {
"auth_mode": "xai-oauth-device-code",
"tokens": {
"access_token": credential.access_token,
"refresh_token": credential.refresh_token,
},
"expires_at": credential.expires_at,
"obtained_at": credential.obtained_at,
"scope": credential.scope,
"token_endpoint": credential.token_endpoint,
}
parent = token_path.parent
parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=".xai_oauth.", suffix=".json.tmp", dir=str(parent))
try:
with os.fdopen(fd, "w") as handle:
json.dump(payload, handle, indent=2)
handle.flush()
os.fsync(handle.fileno())
with contextlib.suppress(OSError):
os.chmod(tmp_path, 0o600)
os.replace(tmp_path, token_path)
except Exception:
with contextlib.suppress(OSError):
os.unlink(tmp_path)
raise
with contextlib.suppress(OSError):
os.chmod(token_path, 0o600)
def quarantine_credential(token_path: Path, *, code: str, reason: str) -> None:
"""Strip the dead tokens from the store and record why.
A grant the token endpoint has rejected terminally cannot be revived by
retrying, so the tokens are removed and the next process fails fast on the
login remediation instead of re-attempting a refresh that cannot succeed.
Only the error code and reason are recorded never a token value. Must be
called while holding :func:`token_store_lock`.
"""
try:
with open(token_path) as handle:
data = json.load(handle)
current: dict[str, Any] = data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError):
current = {}
current["tokens"] = {}
current["expires_at"] = None
current["last_auth_error"] = {
"code": code,
"reason": reason,
"at": time.time(),
}
parent = token_path.parent
parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=".xai_oauth.", suffix=".json.tmp", dir=str(parent))
try:
with os.fdopen(fd, "w") as handle:
json.dump(current, handle, indent=2)
handle.flush()
os.fsync(handle.fileno())
with contextlib.suppress(OSError):
os.chmod(tmp_path, 0o600)
os.replace(tmp_path, token_path)
except Exception:
with contextlib.suppress(OSError):
os.unlink(tmp_path)
raise
# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------
def validate_oauth_endpoint(url: str, *, field: str) -> str:
"""Return ``url`` if it is HTTPS on the xAI origin, else raise.
The discovery result is cached in the store and every later refresh posts
the refresh token to the cached ``token_endpoint``, so a substituted
endpoint would turn one bad discovery response into a standing credential
leak. Pinning scheme and host to ``x.ai`` (or a ``*.x.ai`` subdomain) is
what stops the substitution outliving the request that carried it.
"""
parsed = urlparse(url)
if parsed.scheme != "https":
raise XaiOAuthDiscoveryError(f"xAI OIDC discovery returned a non-HTTPS {field}: {url!r}")
host = (parsed.hostname or "").lower()
if not host:
raise XaiOAuthDiscoveryError(f"xAI OIDC discovery {field} has no hostname: {url!r}")
if host != "x.ai" and not host.endswith(".x.ai"):
raise XaiOAuthDiscoveryError(
f"xAI OIDC discovery {field} host {host!r} is not on the xAI origin (expected x.ai or a *.x.ai subdomain)"
)
return url
def discover_endpoints(client: httpx.Client) -> dict[str, str]:
"""Fetch the OIDC discovery document and return the endpoints we use.
Returns ``token_endpoint`` and ``device_authorization_endpoint``. Both are
validated against the xAI origin. When the document omits
``device_authorization_endpoint``, :data:`XAI_OAUTH_DEVICE_CODE_URL` is used
instead RFC 8628 registers the metadata key but does not require issuers
to publish it.
"""
try:
response = client.get(XAI_OAUTH_DISCOVERY_URL, headers={"Accept": "application/json"})
except httpx.RequestError as exc:
raise XaiOAuthDiscoveryError(f"xAI OIDC discovery request failed: {type(exc).__name__}") from exc
if response.status_code != 200:
raise XaiOAuthDiscoveryError(f"xAI OIDC discovery returned HTTP {response.status_code}")
try:
payload = response.json()
except (json.JSONDecodeError, ValueError) as exc:
raise XaiOAuthDiscoveryError("xAI OIDC discovery returned a non-JSON body") from exc
if not isinstance(payload, dict):
raise XaiOAuthDiscoveryError("xAI OIDC discovery body was not a JSON object")
token_endpoint = str(payload.get("token_endpoint") or "").strip()
if not token_endpoint:
raise XaiOAuthDiscoveryError("xAI OIDC discovery body has no token_endpoint")
device_endpoint = str(payload.get("device_authorization_endpoint") or "").strip() or XAI_OAUTH_DEVICE_CODE_URL
return {
"token_endpoint": validate_oauth_endpoint(token_endpoint, field="token_endpoint"),
"device_authorization_endpoint": validate_oauth_endpoint(
device_endpoint, field="device_authorization_endpoint"
),
}
# ---------------------------------------------------------------------------
# Device-code login (interactive only)
# ---------------------------------------------------------------------------
def request_device_code(client: httpx.Client, *, device_endpoint: str, client_id: str, scope: str) -> dict[str, Any]:
"""Start the device-code flow and return the authorization response.
Raises :class:`XaiOAuthError` when the endpoint answers non-200 or omits a
field RFC 8628 section 3.2 makes required.
"""
response = client.post(
device_endpoint,
headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"},
data={"client_id": client_id, "scope": scope},
)
if response.status_code != 200:
raise XaiOAuthError(f"xAI device-code request failed with HTTP {response.status_code}")
try:
payload = response.json()
except (json.JSONDecodeError, ValueError) as exc:
raise XaiOAuthError("xAI device-code response was not JSON") from exc
if not isinstance(payload, dict):
raise XaiOAuthError("xAI device-code response was not a JSON object")
missing = [key for key in ("device_code", "user_code", "verification_uri", "expires_in") if key not in payload]
if missing:
raise XaiOAuthError(f"xAI device-code response is missing fields: {', '.join(missing)}")
return payload
def poll_device_token(
client: httpx.Client,
*,
token_endpoint: str,
device_code: str,
client_id: str,
expires_in: int,
interval: int,
sleeper: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
) -> dict[str, Any]:
"""Poll the token endpoint until the user approves, or the code dies.
Follows RFC 8628 section 3.5: waits ``interval`` seconds between polls,
widens that interval by 5 seconds on ``slow_down``, keeps polling on
``authorization_pending``, and raises on ``expired_token`` or any other
error code. Stops once ``expires_in`` seconds have elapsed.
``sleeper`` and ``monotonic`` are injected so the timing rules can be
asserted without wall-clock waits.
"""
deadline = monotonic() + max(1, int(expires_in))
current_interval = max(1, int(interval))
while monotonic() < deadline:
response = client.post(
token_endpoint,
headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"},
data={
"grant_type": DEVICE_CODE_GRANT_TYPE,
"client_id": client_id,
"device_code": device_code,
},
)
if response.status_code == 200:
payload = response.json()
if not isinstance(payload, dict) or not payload.get("access_token"):
raise XaiOAuthError("xAI device-code token response carried no access_token")
if not payload.get("refresh_token"):
raise XaiOAuthError("xAI device-code token response carried no refresh_token")
return payload
try:
error_payload = response.json()
except (json.JSONDecodeError, ValueError) as exc:
raise XaiOAuthError(
f"xAI device-code polling returned a non-JSON HTTP {response.status_code} body"
) from exc
error_code = str(error_payload.get("error") or "") if isinstance(error_payload, dict) else ""
if error_code == "authorization_pending":
sleeper(current_interval)
continue
if error_code == "slow_down":
current_interval += 5
sleeper(current_interval)
continue
if error_code == "expired_token":
raise XaiOAuthError("The xAI device code expired before it was approved. Start the login again.")
raise XaiOAuthError(f"xAI device-code polling failed with error {error_code or 'unknown'}")
raise XaiOAuthError("Timed out waiting for xAI device-code approval.")
def device_code_login(
*,
token_path: Path | None = None,
client_id: str | None = None,
scope: str | None = None,
timeout_seconds: float | None = None,
writer: Callable[[str], None] = print,
) -> Path:
"""Run the interactive device-code login and write the credential store.
Prints the verification URL and user code for the operator to approve in a
browser, then polls until the grant lands. The user code is written through
``writer`` (stdout by default) and is never passed to the logger.
Returns the store path that was written. This function is only reachable
from the module's ``login`` entrypoint — no request path calls it.
"""
path = token_path or default_token_path()
resolved_client_id = client_id or os.environ.get(ENV_CLIENT_ID, "").strip() or DEFAULT_CLIENT_ID
resolved_scope = scope or os.environ.get(ENV_SCOPE, "").strip() or DEFAULT_SCOPE
timeout = (
timeout_seconds
if timeout_seconds is not None
else _env_float(ENV_REFRESH_TIMEOUT_SECONDS, DEFAULT_REFRESH_TIMEOUT_SECONDS)
)
with httpx.Client(timeout=httpx.Timeout(max(20.0, timeout)), headers={"Accept": "application/json"}) as client:
endpoints = discover_endpoints(client)
device = request_device_code(
client,
device_endpoint=endpoints["device_authorization_endpoint"],
client_id=resolved_client_id,
scope=resolved_scope,
)
verification = str(device.get("verification_uri_complete") or device["verification_uri"])
writer("")
writer("To authorize Hindsight against your SuperGrok subscription:")
writer(f" 1. Open: {verification}")
writer(f" 2. If prompted, enter code: {device['user_code']}")
writer("Waiting for approval...")
payload = poll_device_token(
client,
token_endpoint=endpoints["token_endpoint"],
device_code=str(device["device_code"]),
client_id=resolved_client_id,
expires_in=int(device["expires_in"]),
interval=int(device.get("interval") or 5),
)
now = time.time()
expires_in = _coerce_float(payload.get("expires_in"))
credential = StoredCredential(
access_token=str(payload["access_token"]),
refresh_token=str(payload["refresh_token"]),
expires_at=(now + expires_in) if expires_in is not None else None,
obtained_at=now,
scope=str(payload.get("scope") or resolved_scope),
token_endpoint=endpoints["token_endpoint"],
)
with token_store_lock(path):
write_credential(path, credential)
logger.info(
"xai-oauth credential stored at %s (scope=%s, expires_at=%s)",
path,
credential.scope,
credential.expires_at,
)
writer(f"Stored the xai-oauth credential at {path}")
return path
def _env_float(name: str, default: float) -> float:
raw = os.environ.get(name, "").strip()
if not raw:
return default
try:
return float(raw)
except ValueError:
logger.warning("Ignoring non-numeric %s; using %s", name, default)
return default
# ---------------------------------------------------------------------------
# Manager
# ---------------------------------------------------------------------------
class XaiOAuthManager:
"""Keeps one xAI OAuth credential usable for unattended requests.
Proactively refreshes a token inside the skew window and reactively
refreshes once when the API rejects one. Never starts the device-code flow:
a credential that only a login can restore raises
:class:`XaiOAuthLoginRequiredError` instead.
"""
def __init__(
self,
token_path: Path | None = None,
*,
client_id: str | None = None,
refresh_skew_seconds: float | None = None,
refresh_timeout_seconds: float | None = None,
min_refresh_gap_seconds: float = DEFAULT_MIN_REFRESH_GAP_SECONDS,
http_client: httpx.Client | None = None,
) -> None:
self._token_path = token_path or default_token_path()
self._client_id = client_id or os.environ.get(ENV_CLIENT_ID, "").strip() or DEFAULT_CLIENT_ID
self._skew_seconds = (
refresh_skew_seconds
if refresh_skew_seconds is not None
else _env_float(ENV_REFRESH_SKEW_SECONDS, DEFAULT_REFRESH_SKEW_SECONDS)
)
self._timeout_seconds = (
refresh_timeout_seconds
if refresh_timeout_seconds is not None
else _env_float(ENV_REFRESH_TIMEOUT_SECONDS, DEFAULT_REFRESH_TIMEOUT_SECONDS)
)
self._min_refresh_gap_seconds = min_refresh_gap_seconds
self._owns_client = http_client is None
self._http_client = http_client or httpx.Client(timeout=httpx.Timeout(self._timeout_seconds))
@property
def token_path(self) -> Path:
return self._token_path
# ------------------------------------------------------------------
# Read path
# ------------------------------------------------------------------
def get_access_token(self, min_ttl_seconds: float | None = None) -> str:
"""Return an access token good for at least ``min_ttl_seconds``.
``min_ttl_seconds`` defaults to the configured skew. A credential whose
store recorded no expiry counts as having none left, so it is refreshed
rather than used on trust.
"""
required_ttl = self._skew_seconds if min_ttl_seconds is None else max(min_ttl_seconds, 0.0)
credential = read_credential(self._token_path)
if credential.access_token and credential.seconds_left() > required_ttl:
return credential.access_token
return self.refresh(reason="proactive (token inside the refresh window)", required_ttl=required_ttl)
def refresh(
self,
*,
reason: str = "",
required_ttl: float | None = None,
rejected_token: str | None = None,
) -> str:
"""Refresh the stored credential and return the new access token.
Takes the store lock, then re-reads the store: when a sibling manager's
refresh already produced a token that satisfies ``required_ttl`` (or,
for a reactive refresh, any token other than ``rejected_token``), that
token is returned and no request is sent. This is what keeps N provider
instances sharing one credential down to one refresh.
Raises
------
XaiOAuthLoginRequiredError:
When the token endpoint terminally rejects the grant, or the store
holds nothing to refresh.
XaiOAuthRefreshError:
On a transient failure, and when the anti-spin minimum gap has not
elapsed since the stored credential was obtained.
"""
ttl = self._skew_seconds if required_ttl is None else required_ttl
with token_store_lock(self._token_path, timeout_seconds=max(AUTH_LOCK_TIMEOUT_SECONDS, self._timeout_seconds)):
credential = read_credential(self._token_path)
if rejected_token is None:
if credential.access_token and credential.seconds_left() > ttl:
logger.debug("xai-oauth refresh skipped: the store already holds a token outside the window")
return credential.access_token
elif credential.access_token and credential.access_token != rejected_token:
logger.debug("xai-oauth refresh skipped: the store already holds a token newer than the rejected one")
return credential.access_token
gap = time.time() - credential.obtained_at
if gap < self._min_refresh_gap_seconds:
raise XaiOAuthRefreshError(
f"xai-oauth refused to refresh again {gap:.1f}s after the stored credential was obtained "
f"(minimum gap {self._min_refresh_gap_seconds:.0f}s). The upstream is rejecting a token that "
"was just issued."
)
return self._refresh_locked(credential, reason=reason)
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _token_endpoint(self, credential: StoredCredential) -> str:
if credential.token_endpoint:
return validate_oauth_endpoint(credential.token_endpoint, field="token_endpoint")
return discover_endpoints(self._http_client)["token_endpoint"]
def _refresh_locked(self, credential: StoredCredential, *, reason: str) -> str:
"""Exchange the refresh token and persist the result. Lock must be held.
A terminal status (see :data:`TERMINAL_REFRESH_STATUSES`) is retried
exactly once a single network-level oddity should not cost the
operator a re-login and then, unless the store has meanwhile moved to
a different grant, quarantines the store and raises the login
remediation.
"""
endpoint = self._token_endpoint(credential)
log_reason = f" ({reason})" if reason else ""
logger.info("Refreshing the xai-oauth access token%s", log_reason)
last_status: int | None = None
for attempt in (1, 2):
try:
response = self._http_client.post(
endpoint,
headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"},
data={
"grant_type": "refresh_token",
"client_id": self._client_id,
"refresh_token": credential.refresh_token,
},
timeout=self._timeout_seconds,
)
except httpx.RequestError as exc:
raise XaiOAuthRefreshError(f"xai-oauth refresh network error: {type(exc).__name__}") from exc
if response.status_code == 200:
return self._persist_refreshed(credential, response)
last_status = response.status_code
if response.status_code not in TERMINAL_REFRESH_STATUSES:
raise XaiOAuthRefreshError(f"xai-oauth refresh failed with HTTP {response.status_code}")
logger.warning(
"xai-oauth refresh rejected with HTTP %d (attempt %d/2)",
response.status_code,
attempt,
)
# xAI rotates the refresh token on every successful refresh, so a
# rejection can simply mean some other writer spent this one first and
# the store already holds its replacement. Quarantining then would
# destroy a live grant and force an interactive login for nothing. The
# in-process lock plus the recheck in refresh() rule that out among
# this host's managers, but not against a store shared with a host
# whose filesystem does not honour flock — so re-read before wiping.
superseded = self._superseded_access_token(credential)
if superseded is not None:
logger.info("xai-oauth refresh rejection ignored: the store already holds a different grant")
return superseded
quarantine_credential(
self._token_path,
code=f"refresh_rejected_{last_status}",
reason="the token endpoint rejected the refresh_token twice",
)
raise XaiOAuthLoginRequiredError(
f"The xAI token endpoint rejected the stored grant (HTTP {last_status}). "
f"Run the xai-oauth login on the host that owns the token store: {LOGIN_COMMAND}"
)
def _superseded_access_token(self, rejected: StoredCredential) -> str | None:
"""Return a usable access token when the store moved on under us.
``None`` when the store still holds the grant that was just rejected,
or holds nothing readable both cases the caller must quarantine.
"""
try:
current = read_credential(self._token_path)
except XaiOAuthError:
return None
if current.refresh_token == rejected.refresh_token or not current.access_token:
return None
return current.access_token
def _persist_refreshed(self, credential: StoredCredential, response: httpx.Response) -> str:
try:
payload = response.json()
except (json.JSONDecodeError, ValueError) as exc:
raise XaiOAuthRefreshError("xai-oauth refresh returned a non-JSON body") from exc
if not isinstance(payload, dict):
raise XaiOAuthRefreshError("xai-oauth refresh body was not a JSON object")
access_token = str(payload.get("access_token") or "")
if not access_token:
raise XaiOAuthRefreshError("xai-oauth refresh returned no access_token")
now = time.time()
expires_in = _coerce_float(payload.get("expires_in"))
refreshed = replace(
credential,
access_token=access_token,
refresh_token=str(payload.get("refresh_token") or credential.refresh_token),
expires_at=(now + expires_in) if expires_in is not None else None,
obtained_at=now,
scope=str(payload.get("scope") or credential.scope),
)
write_credential(self._token_path, refreshed)
logger.info(
"xai-oauth access token refreshed (%d bytes, expires_at=%s, scope=%s)",
len(access_token),
refreshed.expires_at,
refreshed.scope,
)
return access_token
def close(self) -> None:
"""Close the HTTP client when this manager created it."""
if self._owns_client:
self._http_client.close()
# ---------------------------------------------------------------------------
# Entrypoint
# ---------------------------------------------------------------------------
def main(argv: list[str] | None = None) -> int:
"""Command-line entrypoint: ``python -m ...xai_oauth_auth login``."""
parser = argparse.ArgumentParser(prog="xai_oauth_auth", description="Manage the xai-oauth credential store.")
subparsers = parser.add_subparsers(dest="command", required=True)
login = subparsers.add_parser("login", help="Run the interactive xAI device-code login.")
login.add_argument("--token-path", default=None, help="Write the credential here instead of the default store.")
args = parser.parse_args(argv)
if args.command == "login":
try:
device_code_login(token_path=Path(args.token_path) if args.token_path else None)
except XaiOAuthError as exc:
print(f"xai-oauth login failed: {exc}", file=sys.stderr)
return 1
return 0
return 1 # pragma: no cover - argparse rejects unknown commands first
if __name__ == "__main__": # pragma: no cover - process entrypoint
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
@@ -8,6 +8,7 @@ The reflect agent uses hierarchical retrieval:
"""
import json
from datetime import datetime, timezone
from typing import Any
from .tokenization import count_cl100k_tokens
@@ -20,6 +21,20 @@ _DEFAULT_ROLE = "You are a reflection agent that answers questions by reasoning
_DEFAULT_FINAL_ROLE = "You are a thoughtful assistant that synthesizes answers from retrieved memories."
def _current_utc_datetime() -> str:
"""Return the current UTC date and time for time-relative reflect reasoning.
Minute precision (not seconds) so requests within the same minute share an
identical prompt string the finest granularity that still keeps prompt
caching viable for bursty traffic.
"""
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
def _current_datetime_section() -> str:
return f"## Current Date and Time\nThe current date and time is {_current_utc_datetime()}."
def _extract_directive_rules(directives: list[dict[str, Any]]) -> list[str]:
"""Extract directive rules as a list of strings."""
rules = []
@@ -392,6 +407,13 @@ def build_system_prompt_for_tools(
]
)
# Volatile "now" reference goes here — after all the static instructions and
# right before the bank-specific/custom data. Everything above is identical
# across banks and requests, so it stays a cacheable prefix; only this
# timestamp and the custom tail below fall outside the cache.
parts.append("")
parts.append(_current_datetime_section())
parts.append("")
parts.append(f"## Memory Bank: {name}")
@@ -570,6 +592,9 @@ def build_final_system_prompt(
parts.append(_FINAL_SYSTEM_PROMPT_BASE.format(role_section=role_section))
parts.append(_FINAL_LANGUAGE_RULE)
parts.append(build_directives_reminder(directives) if directives else "")
# Volatile "now" reference last, so the static/per-bank instructions above
# remain a cacheable prefix and only this timestamp falls outside the cache.
parts.append(_current_datetime_section())
return "\n\n".join(p.strip() for p in parts if p.strip()) + output_language_directive(llm_output_language)
@@ -586,7 +611,7 @@ You will be given:
2. CURRENT DOCUMENT (JSON) the existing structured mental model. Each section
has a stable ``id``, a ``heading``, a ``level`` (1..6), and an ordered list
of ``blocks``. Blocks are typed: ``paragraph``, ``bullet_list``,
``ordered_list``, or ``code``.
``ordered_list``, ``code``, or ``table``.
3. NEW INFORMATION SYNTHESIS (markdown) a synthesis showing how the new facts
relate to the document's topic. Use it to understand context and relevance,
but do NOT copy its formatting or wording wholesale.
@@ -648,6 +673,7 @@ Block shapes
- ``{"type": "bullet_list", "items": ["...", "..."]}``
- ``{"type": "ordered_list", "items": ["...", "..."]}``
- ``{"type": "code", "language": "json", "text": "..."}``
- ``{"type": "table", "headers": ["col1", "col2"], "rows": [["a", "b"], ["c", "d"]]}``
OUTPUT FORMAT
Return ONLY a single JSON object on its own, with no prose before or after,
@@ -24,11 +24,11 @@ A document is an ordered list of ``Section``s. Each section has:
- ``heading``: the markdown heading text (without the ``#`` prefix).
- ``level`` : 1 (``#``) … 6 (``######``). Default 2.
- ``blocks``: ordered list of typed blocks paragraph, bullet_list,
ordered_list, code.
ordered_list, code, table.
The schema is intentionally narrow: it covers what real mental-model documents
actually contain (the kind a coding agent writes for itself or a user writes as
a "skill" doc). Tables, images, and raw HTML are out of scope until needed.
a "skill" doc). Images and raw HTML are out of scope until needed.
"""
from __future__ import annotations
@@ -66,8 +66,15 @@ class CodeBlock(BaseModel):
text: str
class TableBlock(BaseModel):
model_config = ConfigDict(extra="forbid")
type: Literal["table"] = "table"
headers: list[str] = Field(default_factory=list)
rows: list[list[str]] = Field(default_factory=list)
Block = Annotated[
Union[ParagraphBlock, BulletListBlock, OrderedListBlock, CodeBlock],
Union[ParagraphBlock, BulletListBlock, OrderedListBlock, CodeBlock, TableBlock],
Field(discriminator="type"),
]
@@ -142,9 +149,38 @@ def render_block(block: Block) -> str:
if isinstance(block, CodeBlock):
fence_lang = block.language or ""
return f"```{fence_lang}\n{block.text}\n```"
if isinstance(block, TableBlock):
# Width is the widest row, not just the header: a row with more cells
# than there are headers would otherwise render cells that GFM drops.
width = max(len(block.headers), *(len(row) for row in block.rows), 0)
if width == 0:
return ""
lines = [_render_table_row(block.headers, width)]
lines.append("| " + " | ".join("---" for _ in range(width)) + " |")
lines.extend(_render_table_row(row, width) for row in block.rows)
return "\n".join(lines)
raise TypeError(f"Unknown block type: {type(block)!r}")
def _escape_table_cell(cell: str) -> str:
"""Escape a cell so it survives a markdown table round-trip.
An unescaped ``|`` would start a new column and a newline would start a new
row, so both are neutralised: pipes are backslash-escaped (the GFM
convention, undone again by :func:`_parse_table_row`) and newlines collapse
to a space, since a table cell is a single line by construction.
"""
escaped = cell.replace("\\", "\\\\").replace("|", "\\|")
return " ".join(escaped.splitlines()).strip()
def _render_table_row(cells: list[str], width: int) -> str:
"""Render one table row, padded with empty cells to ``width`` columns."""
rendered = [_escape_table_cell(cell) for cell in cells]
rendered.extend("" for _ in range(width - len(rendered)))
return "| " + " | ".join(rendered) + " |"
def render_section(section: Section) -> str:
"""Render a section: heading + blank line + blocks separated by blank lines."""
parts = ["#" * section.level + " " + section.heading.strip()]
@@ -178,6 +214,8 @@ _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*$")
_SEPARATOR_RX = re.compile(r"\s*([-*_])\1{2,}\s*")
_TABLE_ROW_RX = re.compile(r"^\s*\|.*\|\s*$")
_TABLE_SEPARATOR_RX = re.compile(r"^\s*\|?[\s:]*-{2,}[\s:|-]*\|?\s*$")
def _split_blocks(lines: list[str]) -> list[list[str]]:
@@ -210,6 +248,60 @@ def _split_blocks(lines: list[str]) -> list[list[str]]:
return chunks
def _parse_table_row(line: str) -> list[str]:
"""Split a ``| a | b |`` line into cells, honouring backslash escapes.
Splitting on every ``|`` would tear a cell whose text contains an escaped
pipe (``\\|``, what :func:`_escape_table_cell` emits) into two columns, so
the line is scanned instead: ``\\|`` and ``\\\\`` unescape to ``|`` and
``\\``, and any other backslash sequence is left verbatim so hand-written
content such as ``C:\\path`` survives.
"""
cells: list[str] = []
buf: list[str] = []
escaped = False
for ch in line.strip():
if escaped:
buf.append(ch if ch in "|\\" else "\\" + ch)
escaped = False
elif ch == "\\":
escaped = True
elif ch == "|":
cells.append("".join(buf))
buf = []
else:
buf.append(ch)
if escaped:
buf.append("\\")
cells.append("".join(buf))
# The leading and trailing pipes of a fully-delimited row produce an empty
# cell on each end that is punctuation, not content.
if cells and cells[0] == "":
cells = cells[1:]
if cells and cells[-1] == "":
cells = cells[:-1]
return [cell.strip() for cell in cells]
def _parse_table_block(chunk: list[str]) -> TableBlock:
"""Parse table lines into a :class:`TableBlock`.
``_parse_block`` only routes here when the chunk contains a separator line,
so the separator is what splits headers from data. Rows above it other than
the first (malformed input: GFM allows exactly one header row) are kept as
data rather than dropped the parser never silently loses content.
"""
rows_raw = [_parse_table_row(line) for line in chunk]
separator_idx = next(i for i, line in enumerate(chunk) if _TABLE_SEPARATOR_RX.match(line))
if separator_idx == 0:
return TableBlock(headers=[], rows=rows_raw[1:])
return TableBlock(
headers=rows_raw[0],
rows=rows_raw[1:separator_idx] + rows_raw[separator_idx + 1 :],
)
def _parse_block(chunk: list[str]) -> Block:
"""Parse a single non-empty chunk into a block."""
if chunk and _FENCE_RX.match(chunk[0]):
@@ -236,6 +328,11 @@ def _parse_block(chunk: list[str]) -> Block:
items.append(m.group(1).strip())
return OrderedListBlock(items=items)
if len(chunk) >= 2 and all(_TABLE_ROW_RX.match(line) for line in chunk):
has_separator = any(_TABLE_SEPARATOR_RX.match(line) for line in chunk)
if has_separator:
return _parse_table_block(chunk)
return ParagraphBlock(text=" ".join(line.strip() for line in chunk).strip())
@@ -23,6 +23,35 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
#: Retrieval plumbing that the reflect agent never reads, dropped from tool
#: results before they reach the model.
#:
#: These are scoring and provenance internals, not evidence: the agent cites by
#: ``id``, ``based_on`` persists only id/text/type/context, and the expand tool
#: takes ``memory_ids`` and resolves chunks server-side -- so nothing downstream
#: needs them, while on real banks they measure several times the size of the
#: observation text they accompany.
#:
#: Identity, text, dates, tags and ``source_fact_ids`` are deliberately kept.
#: So is ``entities``: it carries canonical entity *names* (not ids), which are
#: semantically useful retrieval handles -- the canonical name can differ from
#: the surface text ("Bob" in the text vs canonical "Robert Smith"). Reflect's
#: recalls don't populate it today (``include_entities`` defaults to False), but
#: trimming it would bake in dropping the names if that ever flips on.
_UNREAD_RESULT_FIELDS = ("scores", "metadata", "chunk_id", "document_id")
def _drop_unread_fields(d: dict[str, Any]) -> dict[str, Any]:
"""Strip retrieval plumbing from one serialized tool result.
Mutates and returns ``d``, which is always a fresh ``model_dump()`` by the
time it gets here -- never a caller's dict.
"""
for k in _UNREAD_RESULT_FIELDS:
d.pop(k, None)
return d
def _prune_nulls(d: dict[str, Any]) -> dict[str, Any]:
"""Drop keys whose value is None or an empty collection (``""``, ``[]``, ``{}``).
@@ -228,6 +257,11 @@ async def tool_search_observations(
tags_match=tags_match,
tag_groups=tag_groups,
include_source_facts=include_source_facts,
# Canonical entity names are semantic signal the surface text may lack
# ("Bob" in the text vs canonical "Robert Smith"): they populate each
# result's `entities` field, giving the agent resolved names to cite
# and to pivot follow-up queries on.
include_entities=True,
created_after=created_after,
created_before=created_before,
_connection_budget=1,
@@ -246,8 +280,10 @@ async def tool_search_observations(
return {
"query": query,
"count": len(result.results),
"observations": [_prune_nulls(m.model_dump()) for m in result.results],
"source_facts": {k: _prune_nulls(v.model_dump()) for k, v in (result.source_facts or {}).items()},
"observations": [_drop_unread_fields(_prune_nulls(m.model_dump())) for m in result.results],
"source_facts": {
k: _drop_unread_fields(_prune_nulls(v.model_dump())) for k, v in (result.source_facts or {}).items()
},
"is_stale": is_stale,
"freshness": freshness,
}
@@ -306,6 +342,9 @@ async def tool_recall(
tag_groups=tag_groups,
created_after=created_after,
created_before=created_before,
# See tool_search_observations: resolved entity names on each result
# are worth the one extra lookup query.
include_entities=True,
_connection_budget=connection_budget,
_quiet=True, # Suppress logging for internal operations
include_chunks=include_chunks,
@@ -314,7 +353,11 @@ async def tool_recall(
return {
"query": query,
"memories": [_prune_nulls(m.model_dump()) for m in result.results],
"memories": [_drop_unread_fields(_prune_nulls(m.model_dump())) for m in result.results],
# ``chunks`` is deliberately not trimmed: ChunkInfo carries only
# chunk_text / chunk_index / truncated, so it holds none of the fields
# above and the call would be a no-op. Pinned by
# test_chunk_info_carries_no_unread_fields.
"chunks": {k: _prune_nulls(v.model_dump()) for k, v in (result.chunks or {}).items()},
}
@@ -363,7 +406,7 @@ async def tool_expand(
from ..memories import get_memories
_store = get_memories()
if _store.writes_memory_rows_in_sql:
if _store.writes_memory_rows_in_sql_for(bank_id):
memories = await conn.fetch(
f"""
SELECT id, text, chunk_id, document_id, fact_type, context
@@ -499,7 +499,7 @@ async def list_banks(pool) -> list:
last_write = max(write_times) if write_times else None
fact_count = row["fact_count"]
if not _store.writes_memory_rows_in_sql:
if not _store.writes_memory_rows_in_sql_for(row["bank_id"]):
fact_count = sum(
(await _store.count_memories(conn=conn, fq_table=fq_table, bank_id=row["bank_id"])).values()
)
@@ -14,6 +14,9 @@ from .types import ChunkMetadata
logger = logging.getLogger(__name__)
# Page size for walking the facts a chunk owns out of a store that keeps memories outside SQL.
_OUTGOING_PAGE = 500
def compute_chunk_hash(chunk_text: str) -> str:
"""Compute SHA256 hash of chunk text for delta comparison."""
@@ -55,7 +58,54 @@ async def load_existing_chunks(conn, bank_id: str, document_id: str) -> list[Exi
]
async def delete_chunks_by_ids(conn, chunk_ids: list[str], bank_id: str | None = None, txn=None) -> None:
async def memory_ids_for_chunks(conn, bank_id: str, chunk_ids: list[str]) -> list[str]:
"""Ids of the facts these chunks own, asked of whichever store holds them.
The SQL store keeps ``chunk_id`` as a column; a store that keeps memories outside SQL
carries it in the metadata bag (the same key its ``delete_where`` predicate matches on),
so the two are read differently. Only ``experience``/``world`` units are returned:
observations are not chunk-scoped, and feeding one back as a *source* id would be
meaningless. Paged to exhaustion every id is about to be deleted, and a chunk whose
facts overflow one page must not keep half of them.
"""
from ..memories import META_CHUNK_ID, get_memories
store = get_memories()
if store.writes_memory_rows_in_sql:
rows = await conn.fetch(
f"""
SELECT id
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND chunk_id = ANY($2::text[])
AND fact_type IN ('experience', 'world')
""",
bank_id,
chunk_ids,
)
return [str(row["id"]) for row in rows]
unit_ids: list[str] = []
for chunk_id in chunk_ids:
page_token = ""
while True:
page = await store.scan_memories(
conn=conn,
fq_table=fq_table,
bank_id=bank_id,
fact_types=["experience", "world"],
metadata_equals={META_CHUNK_ID: chunk_id},
limit=_OUTGOING_PAGE,
page_token=page_token,
)
unit_ids.extend(m.unit_id for m in page.memories)
page_token = page.next_page_token
if not page_token:
break
return unit_ids
async def delete_chunks_by_ids(conn, chunk_ids: list[str], bank_id: str | None = None, txn=None, ops=None) -> int:
"""
Delete specific chunks by their IDs.
@@ -66,9 +116,29 @@ async def delete_chunks_by_ids(conn, chunk_ids: list[str], bank_id: str | None =
the store's tombstones must ride the same txn as the replacement writes so they commit
(become visible) together otherwise an aborted re-ingest could drop the old memories
without landing the new ones.
``ops`` is the backend-specific DataAccessOps the observation sweep below needs to choose
the PG (native array) vs Oracle (junction table) read path pass ``pool.ops``.
Returns the number of observations invalidated by the sweep, so the caller can log it.
"""
if not chunk_ids:
return
return 0
# Delete the observations derived from the facts these chunks own, BEFORE the facts
# themselves go. Nothing can reach those observations afterwards: consolidation batches
# are built from facts, so an observation whose sources are all deleted is never selected
# into a batch again, and it stays valid and recallable — stale knowledge from the previous
# version of the document surviving the replace (issue #3294). The full-replace path does
# this in ``handle_document_tracking``; the delta path deletes facts through this cascade
# instead, which is why the sweep has to live here rather than at one of the call sites.
invalidated = 0
if bank_id:
outgoing_unit_ids = await memory_ids_for_chunks(conn, bank_id, chunk_ids)
if outgoing_unit_ids:
from .fact_storage import delete_stale_observations_for_memories
invalidated = await delete_stale_observations_for_memories(conn, bank_id, outgoing_unit_ids, ops=ops)
# The chunks->memory_units FK cascade below does not reach a store that keeps memories
# outside SQL (its memory_units is empty), so drop the memories carrying each deleted
@@ -76,7 +146,7 @@ async def delete_chunks_by_ids(conn, chunk_ids: list[str], bank_id: str | None =
from ..memories import META_CHUNK_ID, DeletePredicate, get_memories
_store = get_memories()
if bank_id and not _store.writes_memory_rows_in_sql:
if bank_id and not _store.writes_memory_rows_in_sql_for(bank_id):
for _cid in chunk_ids:
await _store.delete_where(bank_id, DeletePredicate(metadata_equals={META_CHUNK_ID: _cid}), txn=txn)
@@ -103,7 +173,8 @@ async def delete_chunks_by_ids(conn, chunk_ids: list[str], bank_id: str | None =
ORDER BY
LEAST(ml.from_unit_id, ml.to_unit_id),
GREATEST(ml.from_unit_id, ml.to_unit_id),
ml.link_type
ml.link_type,
COALESCE(ml.entity_id, '00000000-0000-0000-0000-000000000000'::uuid)
FOR UPDATE OF ml
)
DELETE FROM {fq_table("memory_links")} ml
@@ -127,6 +198,7 @@ async def delete_chunks_by_ids(conn, chunk_ids: list[str], bank_id: str | None =
""",
chunk_ids,
)
return invalidated
async def store_chunks_batch(
@@ -167,7 +239,7 @@ async def store_chunks_batch(
# same shape as store_document_text=False, and idempotency is unaffected (content_hash stays).
from ..memories import get_memories
if get_memories().owns_document_store:
if get_memories().owns_document_store_for(bank_id):
store_text = False
# Prepare chunk data for batch insert
@@ -270,6 +270,14 @@ async def handle_document_tracking(
f"[RETAIN] Document {document_id} re-ingested: invalidated "
f"{invalidated} observation(s) derived from {len(existing_unit_ids)} outgoing memory_units"
)
else:
# Logged even at zero: "the sweep matched nothing" and "the sweep never ran"
# are the two candidates whenever orphan observations are reported, and
# without this line they look identical from the outside (issue #3294).
logger.debug(
f"[RETAIN] Document {document_id} re-ingested: no observations derived from "
f"{len(existing_unit_ids)} outgoing memory_units"
)
# Capture link-recompute victims BEFORE the cascade. Same staleness
# applies on upsert as on explicit delete: surviving units in OTHER
# documents that linked to these doomed units are about to lose
@@ -372,7 +380,7 @@ async def _upsert_document_row(
# the bulky body is written to the store up front (orchestrator._store_document_bodies).
from ..memories import get_memories
if get_memories().owns_document_store:
if get_memories().owns_document_store_for(bank_id):
original_text = None
await conn.execute(
f"""
@@ -414,7 +422,7 @@ async def update_memory_units_metadata_and_tags(
from ..memories import MemoryPatch, get_memories
store = get_memories()
if not store.writes_memory_rows_in_sql:
if not store.writes_memory_rows_in_sql_for(bank_id):
# A store that keeps memories outside SQL: page the document's memories and patch each
# one's tags through the store — the UPDATE below is a no-op on its empty memory_units.
page = await store.scan_memories(
@@ -3,6 +3,7 @@ Link creation utilities for temporal, semantic, and entity links.
"""
import logging
import re
import time
from datetime import UTC
@@ -21,6 +22,27 @@ from .types import CausalRelation, EntityResolutionResult
logger = logging.getLogger(__name__)
# Sentinel UUID used in the unique index to represent NULL entity_id
_NIL_ENTITY_UUID = "00000000-0000-0000-0000-000000000000"
# Any run of whitespace, including the \n / \r / \t that extraction sometimes
# leaves inside a candidate entity name.
_WHITESPACE_RUN_RE = re.compile(r"\s+")
def _normalize_entity_name(name: str) -> str:
"""Collapse internal whitespace runs to a single space and strip the ends.
Extraction can hand back names carrying embedded newlines/tabs, which then
become ``entities.canonical_name`` values that shear every line-oriented
consumer (``psql -A`` output, log lines, exports) issue #3275. Case is
deliberately untouched: the entity registry already matches on
``LOWER(canonical_name)``, so lowercasing here would only lose the display
form.
"""
return _WHITESPACE_RUN_RE.sub(" ", name).strip()
# Maximum number of temporal links to keep per unit (from_unit_id).
# Retrieval only reads top 10-20 per unit via LATERAL join, so keeping
# more is wasted storage and write amplification.
@@ -31,7 +53,7 @@ def _cap_links_per_unit(links: list[tuple], max_per_unit: int = MAX_TEMPORAL_LIN
"""Keep only the top-N links per from_unit_id, ranked by weight descending.
Args:
links: List of (from_unit_id, to_unit_id, link_type, weight) tuples.
links: List of (from_unit_id, to_unit_id, link_type, weight, entity_id) tuples.
max_per_unit: Maximum number of links to retain per from_unit_id.
Returns:
@@ -72,7 +94,7 @@ async def _bulk_insert_links(
Args:
conn: Database connection (must be inside a transaction).
links: List of (from_unit_id, to_unit_id, link_type, weight) tuples.
links: List of (from_unit_id, to_unit_id, link_type, weight, entity_id) tuples.
bank_id: Bank identifier stored on memory_links for fast filtering.
chunk_size: Max rows per INSERT statement to avoid query timeouts on
very large tables (100M+ rows).
@@ -100,6 +122,7 @@ async def _bulk_insert_links(
fq_table("memory_links"),
sorted_links,
bank_id,
_NIL_ENTITY_UUID,
exists_clause,
chunk_size,
)
@@ -147,6 +170,12 @@ def _prepare_entities_for_resolution(
"""
Convert LLM entities into the flat format expected by entity resolver.
Candidate names are whitespace-normalized here (see ``_normalize_entity_name``)
and names that are empty afterwards are dropped, so no downstream stage has to
cope with an entity whose canonical name is blank or spans several lines.
Both happen before the flat list and ``entity_to_unit`` are derived, keeping
the resolver's positional invariant (output index-aligned with input) intact.
Returns:
Tuple of (all_entities_flat, all_entities, entity_to_unit) where:
- all_entities_flat: flat list of entity dicts ready for resolve_entities_batch
@@ -155,15 +184,45 @@ def _prepare_entities_for_resolution(
"""
substep_start = time.time()
all_entities = []
dropped_empty = 0
for entity_list in llm_entities:
formatted_entities = []
# Normalization can make two candidates that reached here as distinct
# strings ("Acme\nCorp" from extraction, "Acme Corp" from the caller's
# own entity list) identical, and the upstream dedup in
# entity_processing runs on the raw text. Without this, the same entity
# would be resolved twice for one fact and its mention_count bumped twice.
seen_in_fact: set[str] = set()
for ent in entity_list:
if hasattr(ent, "text"):
formatted_entities.append({"text": ent.text, "type": "CONCEPT"})
raw_text, entity_type = ent.text, "CONCEPT"
elif isinstance(ent, dict):
formatted_entities.append({"text": ent.get("text", ""), "type": ent.get("type", "CONCEPT")})
raw_text, entity_type = ent.get("text", ""), ent.get("type", "CONCEPT")
else:
continue
normalized_text = _normalize_entity_name(raw_text)
if not normalized_text:
# A blank or whitespace-only candidate would otherwise be created
# as an entity with an empty canonical_name — the resolver has no
# guard of its own.
dropped_empty += 1
continue
if normalized_text.lower() in seen_in_fact:
continue
seen_in_fact.add(normalized_text.lower())
formatted_entities.append({"text": normalized_text, "type": entity_type})
all_entities.append(formatted_entities)
if dropped_empty:
_log(
log_buffer,
f" [6.1] Dropped {dropped_empty} empty candidate entity name(s)",
level="debug",
)
total_entities = sum(len(ents) for ents in all_entities)
_log(
log_buffer,
@@ -371,7 +430,7 @@ async def create_temporal_links_batch_per_fact(
for row in rows:
time_diff_h = float(row["time_diff_hours"])
weight = max(0.3, 1.0 - (time_diff_h / time_window_hours))
links.append((row["from_id"], str(row["id"]), "temporal", weight))
links.append((row["from_id"], str(row["id"]), "temporal", weight, None))
# Also compute temporal links WITHIN the new batch (new units to each other)
if len(new_units) > 1:
@@ -396,8 +455,8 @@ async def create_temporal_links_batch_per_fact(
if time_diff_hours <= time_window_hours:
weight = max(0.3, 1.0 - (time_diff_hours / time_window_hours))
# Create bidirectional links
links.append((unit_id, other_id, "temporal", weight))
links.append((other_id, unit_id, "temporal", weight))
links.append((unit_id, other_id, "temporal", weight, None))
links.append((other_id, unit_id, "temporal", weight, None))
# Cap temporal links per unit to avoid write amplification;
# retrieval only reads top 10-20 per unit anyway.
@@ -454,7 +513,7 @@ async def compute_semantic_links_ann(
log_buffer: Optional logging buffer
Returns:
List of (from_id, to_id, "semantic", similarity) tuples
List of (from_id, to_id, "semantic", similarity, None) tuples
where from_id uses placeholder IDs.
"""
if not unit_ids or not embeddings:
@@ -555,7 +614,7 @@ async def compute_semantic_links_ann(
for row in rows:
sim = float(min(1.0, max(0.0, row["similarity"])))
if sim >= threshold:
links.append((row["from_id"], row["to_id"], "semantic", sim))
links.append((row["from_id"], row["to_id"], "semantic", sim, None))
_log(
log_buffer,
@@ -584,7 +643,7 @@ def compute_semantic_links_within_batch(
threshold: Minimum cosine similarity
Returns:
List of (from_id, to_id, "semantic", similarity) tuples
List of (from_id, to_id, "semantic", similarity, None) tuples
"""
if len(unit_ids) < 2:
return []
@@ -619,7 +678,7 @@ def compute_semantic_links_within_batch(
other_idx = other_indices[local_idx]
other_id = unit_ids[other_idx]
similarity = float(min(1.0, max(0.0, similarities[local_idx])))
links.append((unit_id, other_id, "semantic", similarity))
links.append((unit_id, other_id, "semantic", similarity, None))
return links
@@ -797,7 +856,7 @@ async def _write_causal_links_batch(
if from_unit_id == to_unit_id:
continue
links.append((from_unit_id, to_unit_id, relation_type, 1.0))
links.append((from_unit_id, to_unit_id, relation_type, 1.0, None))
if links:
insert_start = time_mod.time()
@@ -908,6 +967,7 @@ async def rematerialize_causal_links(
descriptor.to_unit_id,
descriptor.link_type,
descriptor.weight,
None,
)
for descriptor in parsed
if descriptor is not None
@@ -51,15 +51,20 @@ def utcnow():
return datetime.now(UTC)
def _redact_document_body(body: str, config: Any) -> str:
def redact_document_body(body: str, config: Any) -> str:
"""Apply Memory Defense redaction to a document body.
Per-item screening only scrubs the chunked content that goes through
`screen()`. When a sub-batch carries `document_body_override` (the full
original text of an oversized item see `_split_contents_into_sub_batches`),
that override bypasses screening and would persist verbatim into
`documents.original_text`. Apply the same redactor here so the document
body is scrubbed regardless of which path produced it.
`screen()`. A `document_body_override` (the full original text of an
oversized item see `_split_contents_into_sub_batches`) never goes through
`screen()` and would otherwise persist verbatim into
`documents.original_text`, so the splitting caller runs it through this
redactor once, before handing the same body to every slice.
**Callers of this module must pass an override that is already screened.**
The retain path here deliberately does not re-screen it: every slice of an
oversized item carries the identical body, so re-screening would rescan the
whole document once per sub-batch (issue #3282).
"""
try:
policy = parse_policy(getattr(config, "memory_defense", None))
@@ -75,19 +80,17 @@ def _redact_document_body(body: str, config: Any) -> str:
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.
``documents.original_text`` is sanitized before persistence (the override
arrives Memory Defense redacted see ``redact_document_body``), so apply
the same sanitization 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 ""
sanitized_body = fact_extraction._sanitize_text(document_body_override) or ""
return len(sanitized_body) > len(stored_original_text) and sanitized_body.startswith(stored_original_text)
@@ -467,7 +470,7 @@ def _remap_phase1_results(
# Remap semantic ANN links (from_id uses placeholder)
remapped_semantic = [
(placeholder_to_actual.get(lnk[0], lnk[0]), lnk[1], lnk[2], lnk[3]) for lnk in semantic_ann_links
(placeholder_to_actual.get(lnk[0], lnk[0]), lnk[1], lnk[2], lnk[3], lnk[4]) for lnk in semantic_ann_links
]
return remapped_entity_to_unit, remapped_unit_to_entity_ids, remapped_semantic
@@ -1275,7 +1278,7 @@ async def _store_document_bodies(
from ..memories import get_memories
store = get_memories()
if not store.owns_document_store:
if not store.owns_document_store_for(bank_id):
return
# The record's content_hash must equal what the SQL documents row stores, so a read is
# consistent whichever it comes from: sanitize + sha256 the same combined_content. The
@@ -1367,9 +1370,9 @@ async def _streaming_retain_batch(
# so documents.original_text stores the complete payload, not just this
# slice (issue #1838).
if document_body_override is not None:
# The override is the unmodified original body — apply redaction so
# secrets in oversized inputs don't bypass screening.
combined_content = _redact_document_body(document_body_override, config)
# Already Memory Defense screened by the caller that produced it
# (see redact_document_body) — do not rescan it per slice.
combined_content = document_body_override
else:
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
# Memory: contents_dicts content strings are now captured in combined_content.
@@ -2337,7 +2340,6 @@ async def _try_delta_retain(
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 "
@@ -2526,10 +2528,10 @@ async def _try_delta_retain(
step_start = time.time()
# When this sub-batch is one slice of an oversized item
# split across multiple sub-batches, store the full body
# (issue #1838) instead of just the slice. Redact the
# override since it bypassed per-chunk screening.
# (issue #1838) instead of just the slice. The override
# arrives already screened (see redact_document_body).
if document_body_override is not None:
combined_content = _redact_document_body(document_body_override, config)
combined_content = document_body_override
else:
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
retain_params, merged_tags = _build_retain_params(contents_dicts, document_tags)
@@ -2571,10 +2573,13 @@ async def _try_delta_retain(
for idx in changed_indices + removed_indices
if idx in existing_by_index
]
await chunk_storage.delete_chunks_by_ids(conn, chunks_to_delete, bank_id, txn=_group_txn)
invalidated_obs = await chunk_storage.delete_chunks_by_ids(
conn, chunks_to_delete, bank_id, txn=_group_txn, ops=pool.ops
)
log_buffer.append(
f" Deleted {len(chunks_to_delete)} chunks "
f"({len(changed_indices)} changed + {len(removed_indices)} removed) "
f"({len(changed_indices)} changed + {len(removed_indices)} removed), "
f"invalidated {invalidated_obs} observation(s) "
f"in {time.time() - step_start:.3f}s"
)
@@ -2718,10 +2723,10 @@ async def _delta_metadata_only(
)
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.
# full original body (issue #1838) instead of just the slice. The
# override arrives already screened (see redact_document_body).
if document_body_override is not None:
combined_content = _redact_document_body(document_body_override, config)
combined_content = document_body_override
else:
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
retain_params, merged_tags = _build_retain_params(contents_dicts, document_tags)
@@ -5,9 +5,122 @@ vector distance (pgvector), full-text search (VectorChord BM25 / tsvector),
and other non-portable patterns.
"""
from dataclasses import dataclass
from ..._text_search import mental_models_text_document
from .base import SQLDialect
@dataclass(frozen=True)
class KnowledgeBm25Arm:
"""Backend-specific BM25 clauses for the knowledge-page full-text arm.
``score_expr`` is a relevance score where higher = more relevant (used in the
SELECT list of the BM25-only fallback). ``order_by`` is the arm's ranking
expression (kept separate so distance-based backends can order by the raw,
index-friendly ``ASC`` distance). ``match_filter`` is a WHERE predicate that
keeps only genuine term matches, already prefixed with ``AND `` empty when
the backend ranks every row and needs no gate.
"""
score_expr: str
order_by: str
match_filter: str
def knowledge_bm25_arm(
text_search_extension: str,
*,
table_alias: str,
text_param: str,
) -> KnowledgeBm25Arm:
"""BM25 clauses for ``search_knowledge_pages`` on a given text-search backend.
Mirrors :meth:`PostgreSQLDialect.build_bm25_arm` (the memory-recall BM25 arm)
but targets the ``mental_models`` BM25 index (``idx_mental_models_text_search``
over the page ``name`` + ``content``) that backs knowledge pages. Without this
dispatch the native tsvector SQL (``ts_rank_cd`` / ``@@``) is sent to every
backend and 500s wherever ``mental_models.search_vector`` is not a tsvector
(see issue #3268).
``table_alias`` is the alias the ``mental_models`` row carries in the query
(``mm``); ``text_param`` is the bind placeholder holding the query text.
``pgroonga`` queries the multilingual expression index over ``name +
content``; ``ensure_text_search_extension`` reconciles ``mental_models`` to
that shape (dummy TEXT ``search_vector``) like every other pgroonga table.
"""
a = table_alias
p = text_param
if text_search_extension == "vchord":
# VectorChord BM25 over the bm25vector search_vector column, identical to
# build_bm25_arm's vchord form. This only returns rows because the
# mental_models write path now tokenizes search_vector for vchord
# (pg_search_vector_expr, native_inline=False) the same way memory_units
# does — the column is plain, not generated, so it must be filled on write.
# <&> is the NEGATIVE score (lower = more relevant); negate it.
expr = f"-({a}.search_vector <&> to_bm25query('idx_mental_models_text_search', tokenize({p}, 'llmlingua2')))"
return KnowledgeBm25Arm(
score_expr=expr,
order_by=f"{expr} DESC",
# Gate on a positive score: the operator ranks every row, so a bare
# LIMIT would pad the arm with zero-score non-matches.
match_filter=f"AND {expr} > 0",
)
if text_search_extension == "pg_search":
# ParadeDB pg_search: BM25 index over (id, name, content), key_field='id'.
# Fan the query across both indexed text fields with paradedb.boolean.
score = f"paradedb.score({a}.id)"
return KnowledgeBm25Arm(
score_expr=score,
order_by=f"{score} DESC",
match_filter=(
f"AND {a}.id @@@ paradedb.boolean(should => ARRAY["
f"paradedb.match('name', {p}), paradedb.match('content', {p})])"
),
)
if text_search_extension == "pg_textsearch":
# Timescale pg_textsearch: BM25 index over the `content` column. `<@>` is a
# distance (lower = closer), so negate it for a higher-is-better score and
# order by the raw ASC distance so the index drives the ordering.
distance = f"{a}.content <@> to_bm25query({p}, 'idx_mental_models_text_search')"
return KnowledgeBm25Arm(
score_expr=f"-({distance})",
order_by=f"{distance} ASC",
match_filter="",
)
if text_search_extension == "pgroonga":
# Same operator/score pair as build_bm25_arm's pgroonga form. The filter
# repeats idx_mental_models_text_search's indexed expression verbatim (via
# the shared helper) so the planner can select that expression index —
# pgroonga_score() only returns a real score off a pgroonga index scan and
# silently reads 0 for every row otherwise, so the id tiebreak keeps the
# arm's ordering deterministic if the planner ever picks another plan.
# pgroonga_query_escape neutralises operator characters in user text.
score = f"pgroonga_score({a}.tableoid, {a}.ctid)"
document = mental_models_text_document(a)
return KnowledgeBm25Arm(
score_expr=score,
order_by=f"{score} DESC, {a}.id",
match_filter=f"AND {document} &@~ pgroonga_query_escape({p})",
)
# native: generated tsvector over name + content.
# The generating expression hard-codes the 'english' config (see the
# learnings/pinned_reflections migration), so query with 'english' regardless
# of the configured native language.
score = f"ts_rank_cd({a}.search_vector, websearch_to_tsquery('english', {p}))"
return KnowledgeBm25Arm(
score_expr=score,
order_by=f"{score} DESC",
match_filter=f"AND {a}.search_vector @@ websearch_to_tsquery('english', {p})",
)
class PostgreSQLDialect(SQLDialect):
"""SQL dialect for PostgreSQL (asyncpg)."""
@@ -13,12 +13,15 @@ import io
import json
import logging
import zipfile
from collections.abc import Iterator
from dataclasses import dataclass, field
from datetime import UTC, date, datetime
from decimal import Decimal
from typing import Any
from uuid import UUID
import anyio.to_thread
from ..causal_links import CAUSAL_LINK_TYPES
from ..db_utils import acquire_with_retry
from ..schema import fq_table
@@ -31,6 +34,7 @@ from .schema import (
TransferChunk,
TransferDocument,
TransferFact,
TransferKnowledgePage,
TransferManifest,
TransferObservation,
TransferObservationSource,
@@ -88,12 +92,6 @@ _SKIP_TABLES = frozenset(
# to fresh ids, so carrying them would only produce dangling associations.
# Revert anything worth keeping on the source before migrating.
"invalidated_memory_units",
# Knowledge-base folder/page tree (client-managed metadata over the carried
# mental models). Not carried yet: its self-referential parent_id FK needs a
# parents-first (topological) restore order, which the generic per-row
# _restore_rows doesn't provide — a follow-up. The mental models themselves
# ARE carried, so the target can recreate the tree.
"knowledge_pages",
}
)
# Derived columns dropped from carried rows so the target regenerates them with
@@ -208,11 +206,44 @@ async def export_documents(
documents = loaded.documents
observations = await _load_observations(conn, bank_id, loaded.unit_index) if include_observations else []
fact_total = sum(len(document.facts) for document in documents)
manifest = TransferManifest(
schema_version=SCHEMA_VERSION,
source_bank_id=bank_id,
exported_at=datetime.now(UTC),
document_count=len(documents),
fact_count=fact_total,
observation_count=len(observations),
)
# ZIP compression and per-document JSON serialisation are CPU-bound and, on a
# large bank, would block the event loop for seconds (issue #3321). Run the
# assembly in a worker thread so unrelated requests/tasks keep progressing.
archive_bytes = await anyio.to_thread.run_sync(_build_archive_bytes, documents, observations, manifest)
logger.info(
"[transfer] Exported %d document(s), %d fact(s), %d observation(s) from bank %s",
len(documents),
fact_total,
len(observations),
bank_id,
)
return archive_bytes
def _build_archive_bytes(
documents: list[TransferDocument],
observations: list[TransferObservation],
manifest: TransferManifest,
) -> bytes:
"""Serialise the loaded documents/observations/manifest into a ZIP archive.
Pure CPU work (DEFLATE + Pydantic JSON dumps) with no I/O, so it runs off the
event loop via :func:`anyio.to_thread.run_sync`.
"""
archive = io.BytesIO()
fact_total = 0
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
for index, document in enumerate(documents):
fact_total += len(document.facts)
zf.writestr(
f"documents/{index:06d}.json",
document.model_dump_json(indent=2, exclude_none=False),
@@ -222,23 +253,8 @@ async def export_documents(
payload = "[\n" + ",\n".join(o.model_dump_json(indent=2) for o in observations) + "\n]\n"
zf.writestr("observations.json", payload)
manifest = TransferManifest(
schema_version=SCHEMA_VERSION,
source_bank_id=bank_id,
exported_at=datetime.now(UTC),
document_count=len(documents),
fact_count=fact_total,
observation_count=len(observations),
)
zf.writestr("manifest.json", manifest.model_dump_json(indent=2))
logger.info(
"[transfer] Exported %d document(s), %d fact(s), %d observation(s) from bank %s",
len(documents),
fact_total,
len(observations),
bank_id,
)
return archive.getvalue()
@@ -268,6 +284,47 @@ async def _dump_bank_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} for row in rows]
async def _load_knowledge_pages(conn: Any, bank_id: str) -> list[TransferKnowledgePage]:
"""Load the knowledge-base tree (folders + pages) as typed rows.
Ordered parents-before-children (root folders first) via a recursive walk of
``parent_id`` so the archive is deterministic and import can insert in list
order; import re-derives a safe order regardless. IDs, ``parent_id``,
``mental_model_id``, ``managed`` and ``sort_order`` are all preserved.
"""
rows = await conn.fetch(
f"""
WITH RECURSIVE tree AS (
SELECT kp.*, 0 AS depth
FROM {fq_table("knowledge_pages")} kp
WHERE kp.bank_id = $1 AND kp.parent_id IS NULL
UNION ALL
SELECT kp.*, t.depth + 1
FROM {fq_table("knowledge_pages")} kp
JOIN tree t ON kp.parent_id = t.id AND kp.bank_id = $1
)
SELECT id, parent_id, kind, name, mental_model_id, sort_order, managed, created_at, updated_at
FROM tree
ORDER BY depth, sort_order, id
""",
bank_id,
)
return [
TransferKnowledgePage(
id=row["id"],
parent_id=row["parent_id"],
kind=row["kind"],
name=row["name"],
mental_model_id=row["mental_model_id"],
sort_order=row["sort_order"],
managed=row["managed"],
created_at=row["created_at"],
updated_at=row["updated_at"],
)
for row in rows
]
async def _dump_history_rows(conn: Any, table: str, bank_id: str) -> list[dict]:
"""Dump a bank-scoped child-history table for carrying across instances.
@@ -314,6 +371,7 @@ async def export_bank(
bank_rows = {table: await _dump_bank_rows(conn, table, bank_id) for table in _BANK_ROW_TABLES}
for table in CARRIED_HISTORY_TABLES:
bank_rows[table] = await _dump_history_rows(conn, table, bank_id)
knowledge_pages = await _load_knowledge_pages(conn, 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}
@@ -331,6 +389,14 @@ async def export_bank(
for table, rows in bank_rows.items():
zf.writestr(f"{table}.json", json.dumps(rows, indent=2, default=_row_json_default))
# Typed knowledge-page tree (parent-first). Written even when empty so the
# importer can distinguish "no pages" from a pre-tree archive.
zf.writestr(
"knowledge_pages.json",
"[\n" + ",\n".join(p.model_dump_json(indent=2) for p in knowledge_pages) + "\n]\n"
if knowledge_pages
else "[]\n",
)
for table, rows in history_rows.items():
zf.writestr(f"history/{table}.json", json.dumps(rows, indent=2, default=_row_json_default))
@@ -343,6 +409,7 @@ async def export_bank(
observation_count=len(observations),
archive_type="bank",
mental_model_count=len(bank_rows.get("mental_models", [])),
knowledge_page_count=len(knowledge_pages),
directive_count=len(bank_rows.get("directives", [])),
webhook_count=len(bank_rows.get("webhooks", [])),
includes_history=include_history,
@@ -352,12 +419,13 @@ async def export_bank(
logger.info(
"[transfer] Exported bank %s: %d document(s), %d fact(s), %d observation(s), "
"%d mental model(s), %d directive(s), %d webhook(s)%s",
"%d mental model(s), %d knowledge page(s), %d directive(s), %d webhook(s)%s",
bank_id,
len(documents),
fact_total,
len(observations),
len(bank_rows.get("mental_models", [])),
len(knowledge_pages),
len(bank_rows.get("directives", [])),
len(bank_rows.get("webhooks", [])),
" (with history)" if include_history else "",
@@ -539,25 +607,41 @@ async def _load_facts(conn: Any, bank_id: str, doc_ids: list[str], include_lifec
return loaded
# A whole-bank export can index hundreds of thousands of memory units. Passing
# every unit id as a single ``ANY($1)`` parameter (issue #3321) inflates the
# query, spikes memory, and pins the connection while one enormous scan runs.
# Fetch the attach queries in bounded batches instead: each unit id belongs to
# exactly one batch, so per-fact ordering is preserved, and awaiting between
# batches yields the event loop.
_ATTACH_BATCH_SIZE = 5000
def _iter_id_batches(ids: list[Any], batch_size: int = _ATTACH_BATCH_SIZE) -> Iterator[list[Any]]:
"""Yield ``ids`` in fixed-size chunks (bounds SQL ``ANY`` parameter size)."""
for start in range(0, len(ids), batch_size):
yield ids[start : start + batch_size]
async def _attach_entities(conn: Any, loaded: _LoadedFacts) -> None:
"""Populate each fact's ``entities`` list with its entities' canonical names."""
if not loaded.unit_index:
return
rows = await conn.fetch(
f"""
SELECT ue.unit_id, e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON e.id = ue.entity_id
WHERE ue.unit_id = ANY($1)
ORDER BY e.canonical_name
""",
list(loaded.unit_index.keys()),
)
for row in rows:
location = loaded.unit_index.get(row["unit_id"])
if location is None:
continue
loaded.facts_by_doc[location.document_id][location.ordinal].entities.append(row["canonical_name"])
for batch in _iter_id_batches(list(loaded.unit_index.keys())):
rows = await conn.fetch(
f"""
SELECT ue.unit_id, e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON e.id = ue.entity_id
WHERE ue.unit_id = ANY($1)
ORDER BY e.canonical_name
""",
batch,
)
for row in rows:
location = loaded.unit_index.get(row["unit_id"])
if location is None:
continue
loaded.facts_by_doc[location.document_id][location.ordinal].entities.append(row["canonical_name"])
async def _attach_causal_relations(conn: Any, loaded: _LoadedFacts) -> None:
@@ -570,27 +654,32 @@ async def _attach_causal_relations(conn: Any, loaded: _LoadedFacts) -> None:
"""
if not loaded.unit_index:
return
rows = await conn.fetch(
f"""
SELECT from_unit_id, to_unit_id, link_type
FROM {fq_table("memory_links")}
WHERE link_type = ANY($1)
AND from_unit_id = ANY($2)
AND to_unit_id = ANY($2)
""",
list(CAUSAL_LINK_TYPES),
list(loaded.unit_index.keys()),
)
for row in rows:
source = loaded.unit_index.get(row["from_unit_id"])
target = loaded.unit_index.get(row["to_unit_id"])
if source is None or target is None:
continue
if source.document_id != target.document_id:
continue
loaded.facts_by_doc[source.document_id][source.ordinal].causal_relations.append(
TransferCausalRelation(
relation_type=row["link_type"],
target_fact_index=target.ordinal,
)
# Batch on ``from_unit_id`` only. The old query also constrained
# ``to_unit_id = ANY(<full set>)``, but splitting one list across both bounds
# would drop edges whose endpoints fall in different batches. Instead we
# filter the target endpoint in Python (``target is None``) exactly as before,
# which keeps every in-set edge while bounding the parameter size.
for batch in _iter_id_batches(list(loaded.unit_index.keys())):
rows = await conn.fetch(
f"""
SELECT from_unit_id, to_unit_id, link_type
FROM {fq_table("memory_links")}
WHERE link_type = ANY($1)
AND from_unit_id = ANY($2)
""",
list(CAUSAL_LINK_TYPES),
batch,
)
for row in rows:
source = loaded.unit_index.get(row["from_unit_id"])
target = loaded.unit_index.get(row["to_unit_id"])
if source is None or target is None:
continue
if source.document_id != target.document_id:
continue
loaded.facts_by_doc[source.document_id][source.ordinal].causal_relations.append(
TransferCausalRelation(
relation_type=row["link_type"],
target_fact_index=target.ordinal,
)
)
@@ -20,6 +20,7 @@ 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.ops_postgresql import pg_search_vector_expr
from ..db_utils import acquire_with_retry
from ..retain import bank_utils, chunk_storage, embedding_processing, fact_storage, link_utils, orchestrator
from ..retain.types import (
@@ -37,6 +38,7 @@ from .schema import (
BankRowsJSONEncoding,
TransferDocument,
TransferFact,
TransferKnowledgePage,
TransferManifest,
TransferObservation,
)
@@ -105,12 +107,36 @@ class ParsedArchive:
observations: list[TransferObservation] = field(default_factory=list)
def _open_archive(archive_bytes: bytes, *, produced_by: str) -> zipfile.ZipFile:
"""Open a transfer archive, rejecting non-archives as caller errors.
Both failure modes here are a wrong file, not a server fault, so they must
surface as ``ValueError`` (the API maps that to a 400 with the message)
a bare ``zipfile.BadZipFile`` or a missing manifest would otherwise escape
as an opaque 500 or an unexplained "manifest.json is missing".
Args:
archive_bytes: The uploaded bytes.
produced_by: How the caller obtains a valid archive, named in the error.
"""
try:
zf = zipfile.ZipFile(io.BytesIO(archive_bytes), "r")
except zipfile.BadZipFile as e:
raise ValueError(f"Invalid transfer archive: the uploaded file is not a readable .zip ({e})") from e
if "manifest.json" not in set(zf.namelist()):
zf.close()
raise ValueError(
f"Invalid transfer archive: manifest.json is missing. This endpoint only accepts a .zip produced "
f"by {produced_by} — it is not a way to upload a zip of ordinary files (PDF, text, Markdown). "
f"Use the file upload / retain endpoint for those."
)
return zf
def parse_archive(archive_bytes: bytes) -> ParsedArchive:
"""Parse and validate a transfer ZIP archive produced by ``export_documents``."""
with zipfile.ZipFile(io.BytesIO(archive_bytes), "r") as zf:
with _open_archive(archive_bytes, produced_by="the document export endpoint") as zf:
names = set(zf.namelist())
if "manifest.json" not in names:
raise ValueError("Invalid transfer archive: manifest.json is missing")
manifest = TransferManifest.model_validate_json(zf.read("manifest.json"))
if manifest.schema_version != SCHEMA_VERSION:
raise ValueError(
@@ -246,6 +272,7 @@ class BankImportResult:
observations_imported: int = 0
mental_models_imported: int = 0
mental_model_history_imported: int = 0
knowledge_pages_imported: int = 0
directives_imported: int = 0
webhooks_imported: int = 0
history_rows_imported: int = 0
@@ -258,16 +285,16 @@ class ParsedBankArchive:
manifest: TransferManifest
# table name -> list of verbatim row dicts (banks, mental_models, directives, webhooks)
bank_rows: dict[str, list[dict]] = field(default_factory=dict)
# Typed knowledge-base tree (folders + pages), restored parent-first.
knowledge_pages: list[TransferKnowledgePage] = field(default_factory=list)
# table name -> rows (audit_log, llm_requests), present only with --include-history
history_rows: dict[str, list[dict]] = field(default_factory=dict)
def parse_bank_archive(archive_bytes: bytes) -> ParsedBankArchive:
"""Parse the bank-level sections of a whole-bank archive (``archive_type='bank'``)."""
with zipfile.ZipFile(io.BytesIO(archive_bytes), "r") as zf:
with _open_archive(archive_bytes, produced_by="the bank export endpoint") as zf:
names = set(zf.namelist())
if "manifest.json" not in names:
raise ValueError("Invalid transfer archive: manifest.json is missing")
manifest = TransferManifest.model_validate_json(zf.read("manifest.json"))
if manifest.archive_type != "bank":
raise ValueError(
@@ -277,12 +304,20 @@ def parse_bank_archive(archive_bytes: bytes) -> ParsedBankArchive:
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 []
# Typed tree — absent on pre-tree archives, which restore with no pages.
knowledge_pages: list[TransferKnowledgePage] = []
if "knowledge_pages.json" in names:
knowledge_pages = [
TransferKnowledgePage.model_validate(p) for p in json.loads(zf.read("knowledge_pages.json"))
]
history_rows: dict[str, list[dict]] = {}
for table in HISTORY_TABLES:
fname = f"history/{table}.json"
if fname in names:
history_rows[table] = json.loads(zf.read(fname))
return ParsedBankArchive(manifest=manifest, bank_rows=bank_rows, history_rows=history_rows)
return ParsedBankArchive(
manifest=manifest, bank_rows=bank_rows, knowledge_pages=knowledge_pages, history_rows=history_rows
)
def _resolve_bank_rows_json_encoding(manifest: TransferManifest) -> BankRowsJSONEncoding:
@@ -349,6 +384,108 @@ async def _restore_rows(
return inserted
async def _regenerate_mental_model_embeddings(embeddings_model: Any, mm_rows: list[dict]) -> dict[str, str]:
"""Re-embed each restored mental model with the *target* model.
Export strips the source embedding (target-derived). Embeds the same
``"{name} {content}"`` text ``create_mental_model`` embeds so a restored model
ranks identically to a freshly written one. Runs off-connection (no DB conn is
held across the embedding call see the retain path); returns id -> vector
literal for the caller to apply in the restore transaction.
"""
if not mm_rows:
return {}
texts = [f"{(r.get('name') or '')} {(r.get('content') or '')}" for r in mm_rows]
vectors = await embedding_processing.generate_embeddings_batch(embeddings_model, texts)
return {r["id"]: str(v) for r, v in zip(mm_rows, vectors, strict=True)}
async def _apply_mental_model_derived_state(
conn: Any,
bank_id: str,
mm_embeddings: dict[str, str],
config: Any,
) -> None:
"""Write the regenerated embedding (and vchord lexical state) onto restored models.
``search_vector`` is rebuilt only for vchord: native's column is GENERATED and
already repopulated when the row was inserted, and pg_search / pg_textsearch /
pgroonga index the base ``name`` / ``content`` columns directly. Same
per-backend expression the live mental-model writes use (``pg_search_vector_expr``).
"""
if not mm_embeddings:
return
sv_expr = pg_search_vector_expr(
config, text_col="name", context_col="content", signals_col=None, native_inline=False
)
sv_clause = f", search_vector = {sv_expr}" if sv_expr else ""
for mm_id, vector in mm_embeddings.items():
await conn.execute(
f"UPDATE {fq_table('mental_models')} SET embedding = $3::vector{sv_clause} WHERE bank_id = $1 AND id = $2",
bank_id,
mm_id,
vector,
)
def _topological_page_order(pages: list[TransferKnowledgePage]) -> list[TransferKnowledgePage]:
"""Order nodes parents-before-children so the self-referential ``parent_id`` FK
always resolves on insert. A node whose parent is absent from the archive (only
possible in a corrupt export) or part of a cycle is emitted last so the FK, not
a silent drop, surfaces it."""
by_id = {p.id: p for p in pages}
ordered: list[TransferKnowledgePage] = []
placed: set[str] = set()
remaining = list(pages)
while remaining:
ready = [p for p in remaining if p.parent_id is None or p.parent_id not in by_id or p.parent_id in placed]
if not ready:
# Unresolvable parents (cycle / dangling) — emit the rest as-is.
ordered.extend(remaining)
break
for p in ready:
ordered.append(p)
placed.add(p.id)
ready_ids = {p.id for p in ready}
remaining = [p for p in remaining if p.id not in ready_ids]
return ordered
async def _restore_knowledge_pages(conn: Any, bank_id: str, pages: list[TransferKnowledgePage]) -> int:
"""Restore the knowledge-base tree into ``bank_id`` parents-first.
IDs, ``parent_id``, ``mental_model_id``, ``managed``, ``sort_order``, name and
timestamps are preserved; ``bank_id`` is applied to the target. Pages are
restored after their backing mental models (the caller sequences that), and
folders before their children (topological order here). ``ON CONFLICT DO
NOTHING`` keeps the import idempotent.
"""
if not pages:
return 0
inserted = 0
for page in _topological_page_order(pages):
await conn.execute(
f"""
INSERT INTO {fq_table("knowledge_pages")}
(id, bank_id, parent_id, kind, name, mental_model_id, sort_order, managed, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9, now()), COALESCE($10, now()))
ON CONFLICT DO NOTHING
""",
page.id,
bank_id,
page.parent_id,
page.kind,
page.name,
page.mental_model_id,
page.sort_order,
page.managed,
page.created_at,
page.updated_at,
)
inserted += 1
return inserted
async def import_bank(
*,
backend: Any,
@@ -395,6 +532,16 @@ async def import_bank(
if "bank_id" in row:
row["bank_id"] = bank_id
# `internal_id` is a globally-unique (banks_internal_id_unique) local identifier
# used only for per-bank index naming — it is NOT part of the bank's logical
# state and nothing in the archive references it. Drop it so the column DEFAULT
# (gen_random_uuid) mints a fresh one on insert. Keeping the source value makes
# the banks INSERT collide with the source bank on a same-instance re-import,
# where `ON CONFLICT DO NOTHING` then silently skips the parent row and every
# child (mental_models, …) trips its bank_id foreign key. See #3270.
for row in parsed.bank_rows.get("banks", []):
row.pop("internal_id", None)
async with acquire_with_retry(backend) as conn:
# Refuse to import into an existing bank — this restores a whole bank, it
# does not merge. Merging would silently mix the archive's config/mental
@@ -451,13 +598,22 @@ async def import_bank(
facts_imported=doc_result.facts_imported,
observations_imported=doc_result.observations_imported,
)
# Re-embed restored mental models off-connection (the source embedding was
# stripped on export), so no DB connection is held across the embedding call.
mm_rows = parsed.bank_rows.get("mental_models", [])
mm_embeddings = await _regenerate_mental_model_embeddings(embeddings_model, mm_rows)
async with acquire_with_retry(backend) as conn:
result.mental_models_imported = await _restore_rows(
conn,
"mental_models",
parsed.bank_rows.get("mental_models", []),
mm_rows,
bank_rows_json_encoding=bank_rows_json_encoding,
)
# Apply the regenerated embedding + backend-specific lexical state onto the
# restored rows (native search_vector already repopulated on insert).
await _apply_mental_model_derived_state(conn, bank_id, mm_embeddings, config)
# Restored after mental_models so the (mental_model_id, bank_id) FK resolves.
result.mental_model_history_imported = await _restore_rows(
conn,
@@ -465,6 +621,9 @@ async def import_bank(
parsed.bank_rows.get("mental_model_history", []),
bank_rows_json_encoding=bank_rows_json_encoding,
)
# Knowledge-base tree after its backing mental models exist (page FK) and
# parents-first (self-referential parent_id FK).
result.knowledge_pages_imported = await _restore_knowledge_pages(conn, bank_id, parsed.knowledge_pages)
result.directives_imported = await _restore_rows(
conn,
"directives",
@@ -488,13 +647,15 @@ async def import_bank(
logger.info(
"[transfer] Imported bank %s: %d doc(s), %d fact(s), %d observation(s), "
"%d mental model(s), %d mm-history row(s), %d directive(s), %d webhook(s), %d history row(s)",
"%d mental model(s), %d mm-history row(s), %d knowledge page(s), %d directive(s), "
"%d webhook(s), %d history row(s)",
bank_id,
result.documents_imported,
result.facts_imported,
result.observations_imported,
result.mental_models_imported,
result.mental_model_history_imported,
result.knowledge_pages_imported,
result.directives_imported,
result.webhooks_imported,
result.history_rows_imported,
@@ -27,6 +27,9 @@ SCHEMA_VERSION = 1
# history is optional and included only when the caller requests it.
CARRIED_HISTORY_TABLES = ("mental_model_history",)
HISTORY_TABLES = ("audit_log", "llm_requests")
# Logical tree carried as typed rows (not raw dicts) and restored parent-first
# after its backing mental models exist.
KNOWLEDGE_TABLES = ("knowledge_pages",)
ObservationScopes = Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]]
BankRowsJSONEncoding = Literal["decoded", "serialized"]
@@ -130,6 +133,29 @@ class TransferDocument(BaseModel):
facts: list[TransferFact] = Field(default_factory=list)
class TransferKnowledgePage(BaseModel):
"""One node of the knowledge-base tree (a folder or a page).
Carried verbatim across a whole-bank transfer so the folder/page hierarchy,
``managed`` flags, and ordering survive. IDs are preserved (a page's
``mental_model_id`` and a node's ``parent_id`` must still resolve on the
target), and ``bank_id`` is re-applied to the target bank on import. A folder
has ``kind='folder'`` and ``mental_model_id=None``; a page has ``kind='page'``
and points at its backing mental model. No derived search state lives here
that is on ``mental_models`` and regenerated on the target.
"""
id: str
parent_id: str | None = None
kind: Literal["folder", "page"]
name: str
mental_model_id: str | None = None
sort_order: int = 0
managed: bool = False
created_at: datetime | None = None
updated_at: datetime | None = None
class TransferManifest(BaseModel):
"""Top-level archive descriptor (``manifest.json``).
@@ -148,6 +174,7 @@ class TransferManifest(BaseModel):
# (also carries bank config, mental models, directives, webhooks).
archive_type: Literal["documents", "bank"] = "documents"
mental_model_count: int = 0
knowledge_page_count: int = 0
directive_count: int = 0
webhook_count: int = 0
# True when --include-history carried audit_log / llm_requests.
@@ -317,6 +317,7 @@ class ConsolidateResult:
class BankReadOperation(StrEnum):
"""Bank-scoped read operation names passed to validate_bank_read."""
EXPORT_KNOWLEDGE_BASE = "export_knowledge_base"
GET_BANK_CONFIG = "get_bank_config"
GET_BANK_PROFILE = "get_bank_profile"
GET_BANK_STATS = "get_bank_stats"
@@ -327,6 +328,8 @@ class BankReadOperation(StrEnum):
GET_ENTITY_GRAPH = "get_entity_graph"
GET_ENTITY_STATE = "get_entity_state"
GET_GRAPH_DATA = "get_graph_data"
GET_KNOWLEDGE_BASE_TREE = "get_knowledge_base_tree"
GET_KNOWLEDGE_PAGE = "get_knowledge_page"
GET_MEMORIES_TIMESERIES = "get_memories_timeseries"
GET_MEMORY_UNIT = "get_memory_unit"
GET_OBSERVATION_HISTORY = "get_observation_history"
@@ -343,6 +346,7 @@ class BankReadOperation(StrEnum):
LIST_TAGS = "list_tags"
LIST_WEBHOOK_DELIVERIES = "list_webhook_deliveries"
LIST_WEBHOOKS = "list_webhooks"
SEARCH_KNOWLEDGE_BASE = "search_knowledge_base"
class BankWriteOperation(StrEnum):
@@ -353,15 +357,20 @@ class BankWriteOperation(StrEnum):
CLEAR_OBSERVATIONS = "clear_observations"
CLEAR_OBSERVATIONS_FOR_MEMORY = "clear_observations_for_memory"
CREATE_DIRECTIVE = "create_directive"
CREATE_KNOWLEDGE_FOLDER = "create_knowledge_folder"
CREATE_KNOWLEDGE_PAGE = "create_knowledge_page"
CREATE_MENTAL_MODEL = "create_mental_model"
CREATE_WEBHOOK = "create_webhook"
DELETE_BANK = "delete_bank"
DELETE_DIRECTIVE = "delete_directive"
DELETE_DOCUMENT = "delete_document"
DELETE_KNOWLEDGE_NODE = "delete_knowledge_node"
DELETE_MENTAL_MODEL = "delete_mental_model"
DELETE_OPERATION = "delete_operation"
DELETE_WEBHOOK = "delete_webhook"
MERGE_BANK_MISSION = "merge_bank_mission"
MOVE_KNOWLEDGE_NODE = "move_knowledge_node"
RENAME_KNOWLEDGE_NODE = "rename_knowledge_node"
REPROCESS_DOCUMENT = "reprocess_document"
RESET_BANK_CONFIG = "reset_bank_config"
RETRY_FAILED_CONSOLIDATION = "retry_failed_consolidation"
@@ -375,6 +384,7 @@ class BankWriteOperation(StrEnum):
UPDATE_BANK_DISPOSITION = "update_bank_disposition"
UPDATE_DIRECTIVE = "update_directive"
UPDATE_DOCUMENT = "update_document"
UPDATE_KNOWLEDGE_PAGE = "update_knowledge_page"
UPDATE_MEMORY_UNIT = "update_memory_unit"
UPDATE_MENTAL_MODEL = "update_mental_model"
UPDATE_WEBHOOK = "update_webhook"
@@ -858,7 +868,9 @@ class OperationValidatorExtension(Extension, ABC):
Validate a bank read operation before execution.
Override to implement custom validation logic for bank reads
(get_bank_profile, get_bank_stats).
(get_bank_profile, get_bank_stats, and the knowledge-base reads
knowledge_base_tree / get_knowledge_page / search_knowledge_base /
export_knowledge_base, which expose mental-model content).
Args:
ctx: Context containing:
@@ -877,7 +889,10 @@ class OperationValidatorExtension(Extension, ABC):
Override to implement custom validation logic for bank writes
(delete_bank, update_bank, update_bank_disposition, set_bank_mission,
merge_bank_mission, clear_observations, clear_observations_for_memory).
merge_bank_mission, clear_observations, clear_observations_for_memory,
and the knowledge-base writes create_knowledge_folder /
create_knowledge_page / update_knowledge_page / rename_knowledge_node /
move_knowledge_node / delete_knowledge_node).
Args:
ctx: Context containing:
@@ -0,0 +1,112 @@
"""DB-free liveness payloads, shared by the API server and the worker.
Liveness answers exactly one question: *is this process wedged beyond
recovery, so that a restart is the only fix?* It must never touch the
database. A liveness probe that runs ``SELECT 1`` turns database
degradation into an outage: every pod fails the probe at once, is killed
mid-flight (claimed async operations are requeued with ``retry_count``
incremented, walking real work toward the permanent-failure cliff), and
then reconnects to re-warm its pool against a database that is already
saturated.
Dependency checks belong in *readiness* (``/health``, ``/health/ready``),
because failing readiness pulls the pod out of the Service instead of
killing it degradation stays degradation.
Serving this handler at all still proves the one condition a restart does
fix: the event loop is scheduling coroutines. Hindsight runs request
handlers and task work on a single loop, so a loop blocked by a synchronous
call cannot answer even a trivial request within the probe timeout (see
``loop_watchdog.py``).
"""
import time
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
# Import time is close enough to process start for a probe payload: this
# module is imported while the app is being constructed, long before the
# server binds its port.
_PROCESS_START = time.monotonic()
def uptime_seconds() -> float:
"""Seconds this process has been running, rounded for readability."""
return round(time.monotonic() - _PROCESS_START, 1)
class LivenessResponse(BaseModel):
"""Payload for the API's DB-free liveness probe."""
model_config = ConfigDict(
json_schema_extra={
"example": {
"status": "alive",
"version": "0.4.0",
"uptime_seconds": 812.4,
}
}
)
status: Literal["alive"] = Field(description='Always "alive" — reaching this handler is the check')
version: str = Field(description="Hindsight version this process is running")
uptime_seconds: float = Field(description="Seconds since the process started")
class WorkerLivenessResponse(LivenessResponse):
"""Payload for the worker's DB-free liveness probe."""
model_config = ConfigDict(
json_schema_extra={
"example": {
"status": "alive",
"version": "0.4.0",
"uptime_seconds": 812.4,
"worker_id": "hindsight-worker-0",
"is_shutdown": False,
"seconds_since_last_poll": 0.4,
}
}
)
worker_id: str = Field(description="Identifier of this worker process")
is_shutdown: bool = Field(description="Whether graceful shutdown has been signalled")
seconds_since_last_poll: float | None = Field(
default=None,
description=(
"Age of the last completed poll cycle, or null before the first one. "
"Reported for alerting only — the endpoint stays 200 however stale it "
"gets, so a saturated database can never trigger a restart."
),
)
def liveness_response() -> LivenessResponse:
"""Build the API liveness payload without touching any dependency."""
# Imported lazily: ``hindsight_api/__init__`` pulls in MemoryEngine, so a
# module-level import here would make this module unusable from anything the
# engine itself imports.
from hindsight_api import __version__
return LivenessResponse(status="alive", version=__version__, uptime_seconds=uptime_seconds())
def worker_liveness_response(
*,
worker_id: str,
is_shutdown: bool,
seconds_since_last_poll: float | None,
) -> WorkerLivenessResponse:
"""Build the worker liveness payload without touching any dependency."""
# Lazy for the same reason as liveness_response() above.
from hindsight_api import __version__
return WorkerLivenessResponse(
status="alive",
version=__version__,
uptime_seconds=uptime_seconds(),
worker_id=worker_id,
is_shutdown=is_shutdown,
seconds_since_last_poll=seconds_since_last_poll,
)
+104 -32
View File
@@ -29,6 +29,7 @@ from sqlalchemy import Connection, create_engine, text
from sqlalchemy.pool import NullPool
from ._pg_search import normalize_pg_search_tokenizer, pg_search_bm25_columns
from ._text_search import mental_models_text_document
from ._vector_index import (
bootstrap_extension,
configured_vector_extension,
@@ -783,6 +784,34 @@ def ensure_vector_extension(
logger.info(f"Successfully reconciled vector indexes for {target_ext}")
def _reconcile_needs_no_backfill(
text_search_extension: str,
table_name: str,
current_column_type: str | None,
current_index_type: str | None,
) -> bool:
"""Is this mismatch safe to reconcile even though the table holds rows?
Only one transition qualifies: ``mental_models`` sitting on the migration-time
native tsvector projection while pgroonga is configured. pgroonga indexes
``name + content`` directly, so the replacement ``search_vector`` is a dummy
column with nothing to backfill dropping the derived tsvector loses no data
the reconciler would have to recompute.
This state exists on every pgroonga deployment because the reconciler used to
check the pre-rename ``reflections`` table name and therefore never converted
mental models (issue #3307). Every other transition (anything writing
``memory_units``, or a target column that stores a per-row tsvector/bm25vector)
needs values only the write path can produce, so it stays fail-closed.
"""
return (
text_search_extension == "pgroonga"
and table_name == "mental_models"
and current_column_type == "tsvector"
and current_index_type in {None, "gin"}
)
def ensure_text_search_extension(
database_url: str,
text_search_extension: str = "native",
@@ -796,7 +825,9 @@ def ensure_text_search_extension(
in the database and adjusts them if necessary:
- If they match configured extension: no action needed
- If they differ and tables are empty: drop old column/index, recreate with new type
- If they differ and tables have data: raise error with migration guidance
- If they differ and tables have data: raise error with migration guidance,
except for the mental-model native-to-pgroonga transition, which needs no
backfill (pgroonga indexes the base columns) and so is safe while populated
Args:
database_url: SQLAlchemy database URL
@@ -816,10 +847,7 @@ def ensure_text_search_extension(
engine = create_engine(to_libpq_url(database_url), poolclass=NullPool)
with engine.connect() as conn:
# Tables with search_vector columns to check
tables_to_check = [
"memory_units",
"reflections", # Renamed from pinned_reflections in p1k2l3m4n5o6 migration
]
tables_to_check = ["memory_units", "mental_models"]
# Determine target column type and index type
if text_search_extension == "vchord":
@@ -829,7 +857,7 @@ def ensure_text_search_extension(
target_column_type = "text"
target_index_type = "bm25"
elif text_search_extension == "pgroonga":
# pgroonga indexes the base text column directly. We keep a dummy
# pgroonga indexes the base text columns directly. We keep a dummy
# TEXT column named search_vector for symmetry with pg_textsearch
# and so the column-type mismatch detection above keeps working.
target_column_type = "text"
@@ -889,13 +917,19 @@ def ensure_text_search_extension(
text("""
SELECT am.amname, pi.indexdef
FROM pg_indexes pi
JOIN pg_class c ON c.relname = pi.indexname
JOIN pg_class c
ON c.relname = pi.indexname
AND c.relnamespace = to_regnamespace(pi.schemaname)
JOIN pg_am am ON am.oid = c.relam
WHERE pi.schemaname = :schema
AND pi.tablename = :table_name
AND pi.indexname LIKE '%text_search%'
AND pi.indexname = :index_name
"""),
{"schema": schema_name, "table_name": table_name},
{
"schema": schema_name,
"table_name": table_name,
"index_name": f"idx_{table_name.replace('.', '_')}_text_search",
},
).fetchone()
current_index_type = current_index_info[0] if current_index_info else None
@@ -926,7 +960,12 @@ def ensure_text_search_extension(
# Check if table has data
row_count = conn.execute(text(f"SELECT COUNT(*) FROM {schema_name}.{table_name}")).scalar()
if row_count > 0:
if row_count > 0 and not _reconcile_needs_no_backfill(
text_search_extension,
table_name,
current_column_type,
current_index_type,
):
tables_with_data.append((table_name, row_count))
else:
logger.debug(f"Text search OK for {table_name}: {current_column_type}/{current_index_type}")
@@ -960,11 +999,17 @@ def ensure_text_search_extension(
f"the following tables contain data: {table_list}. "
f"To change text search extension, you must either:\n"
f" 1. Clear all data: DELETE FROM {schema_name}.memory_units; "
f"DELETE FROM {schema_name}.reflections; then restart\n"
f"DELETE FROM {schema_name}.mental_models; then restart\n"
f" 2. Use the current text search extension (set HINDSIGHT_API_TEXT_SEARCH_EXTENSION='{current_ext}')"
)
# Tables are empty, safe to recreate columns/indexes
# Tables are empty, except for the backfill-free mental-model
# native-to-pgroonga transition admitted above.
#
# Every statement below is written to be safely re-executable: replicas
# boot concurrently during a rolling restart and each runs this
# reconciliation, so a plain CREATE/ADD would crash whichever replica
# loses the race to the first one's committed DDL.
logger.info(f"Recreating text search columns/indexes for {text_search_extension}")
for table_name, current_col_type, current_idx_type, _was_pg_search in mismatched_tables:
@@ -987,14 +1032,17 @@ def ensure_text_search_extension(
logger.info(f"Creating bm25vector column on {table_name}")
# Note: vchord_bm25 extension creates types in bm25_catalog schema
conn.execute(
text(f"ALTER TABLE {schema_name}.{table_name} ADD COLUMN search_vector bm25_catalog.bm25vector")
text(
f"ALTER TABLE {schema_name}.{table_name} "
f"ADD COLUMN IF NOT EXISTS search_vector bm25_catalog.bm25vector"
)
)
# Create BM25 index
logger.info(f"Creating BM25 index on {table_name}")
conn.execute(
text(f"""
CREATE INDEX idx_{table_name.replace(".", "_")}_text_search
CREATE INDEX IF NOT EXISTS idx_{table_name.replace(".", "_")}_text_search
ON {schema_name}.{table_name}
USING bm25 (search_vector bm25_catalog.bm25_ops)
""")
@@ -1002,19 +1050,21 @@ def ensure_text_search_extension(
elif text_search_extension == "pg_textsearch":
logger.info(f"Creating TEXT column on {table_name}")
# Dummy TEXT column for consistency (indexes operate on base columns)
conn.execute(text(f"ALTER TABLE {schema_name}.{table_name} ADD COLUMN search_vector TEXT"))
conn.execute(
text(f"ALTER TABLE {schema_name}.{table_name} ADD COLUMN IF NOT EXISTS search_vector TEXT")
)
# Create BM25 index on expression
logger.info(f"Creating BM25 index on {table_name}")
# Different expression for each table
if table_name == "memory_units":
index_expr = "(COALESCE(text, '') || ' ' || COALESCE(context, ''))"
else: # reflections
index_expr = "(COALESCE(name, '') || ' ' || content)"
else: # mental_models
index_expr = mental_models_text_document()
conn.execute(
text(f"""
CREATE INDEX idx_{table_name.replace(".", "_")}_text_search
CREATE INDEX IF NOT EXISTS idx_{table_name.replace(".", "_")}_text_search
ON {schema_name}.{table_name}
USING bm25({index_expr})
WITH (text_config='english')
@@ -1031,18 +1081,20 @@ def ensure_text_search_extension(
raise
logger.info(f"Creating dummy TEXT search_vector on {table_name} for pgroonga")
# pgroonga indexes the base text column directly, but we keep a
# pgroonga indexes the base text columns directly, but we keep a
# dummy search_vector column for symmetry with pg_textsearch and
# so the column-type mismatch detection above keeps working.
conn.execute(text(f"ALTER TABLE {schema_name}.{table_name} ADD COLUMN search_vector TEXT"))
conn.execute(
text(f"ALTER TABLE {schema_name}.{table_name} ADD COLUMN IF NOT EXISTS search_vector TEXT")
)
# pgroonga index expression mirrors pg_textsearch
if table_name == "memory_units":
index_expr = (
"(COALESCE(text, '') || ' ' || COALESCE(context, '') || ' ' || COALESCE(text_signals, ''))"
)
else: # reflections
index_expr = "(COALESCE(name, '') || ' ' || content)"
else: # mental_models — knowledge_bm25_arm repeats this verbatim
index_expr = mental_models_text_document()
logger.info(f"Creating pgroonga index on {table_name}")
# TokenBigram is the polyglot default — falls back to whitespace
@@ -1051,7 +1103,7 @@ def ensure_text_search_extension(
# case folding, etc.) which materially improves Japanese recall.
conn.execute(
text(f"""
CREATE INDEX idx_{table_name.replace(".", "_")}_text_search
CREATE INDEX IF NOT EXISTS idx_{table_name.replace(".", "_")}_text_search
ON {schema_name}.{table_name}
USING pgroonga ({index_expr})
WITH (tokenizer='TokenBigram', normalizer='NormalizerNFKC150')
@@ -1060,7 +1112,9 @@ def ensure_text_search_extension(
elif text_search_extension == "pg_search":
logger.info(f"Creating TEXT column on {table_name}")
# Dummy TEXT column for schema symmetry; pg_search indexes operate on base columns.
conn.execute(text(f"ALTER TABLE {schema_name}.{table_name} ADD COLUMN search_vector TEXT"))
conn.execute(
text(f"ALTER TABLE {schema_name}.{table_name} ADD COLUMN IF NOT EXISTS search_vector TEXT")
)
# ParadeDB BM25 index over the table's primary key and text columns.
# Column list mirrors what the initial / text_signals migrations create.
@@ -1070,7 +1124,7 @@ def ensure_text_search_extension(
("text", "context", "text_signals"),
pg_search_tokenizer,
)
else: # reflections
else: # mental_models
bm25_cols = pg_search_bm25_columns(
"id",
("name", "content"),
@@ -1080,7 +1134,7 @@ def ensure_text_search_extension(
logger.info(f"Creating ParadeDB BM25 index on {table_name}")
conn.execute(
text(f"""
CREATE INDEX idx_{table_name.replace(".", "_")}_text_search
CREATE INDEX IF NOT EXISTS idx_{table_name.replace(".", "_")}_text_search
ON {schema_name}.{table_name}
USING bm25 ({bm25_cols})
WITH (key_field='id')
@@ -1088,17 +1142,35 @@ def ensure_text_search_extension(
)
else: # native
logger.info(f"Creating tsvector column on {table_name}")
# Plain tsvector column. The application populates search_vector
# at INSERT time via to_tsvector($lang, ...) using the configured
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE — see
# ops_postgresql.insert_facts_batch.
conn.execute(text(f"ALTER TABLE {schema_name}.{table_name} ADD COLUMN search_vector tsvector"))
if table_name == "mental_models":
# No write path populates mental_models.search_vector for
# native (pg_search_vector_expr passes native_inline=False),
# so it must be GENERATED exactly like the learnings /
# pinned_reflections migration creates it — a plain column
# here would stay NULL and silently empty knowledge search.
# The 'english' config is hard-coded there and in
# knowledge_bm25_arm's native branch; keep all three in step.
conn.execute(
text(f"""
ALTER TABLE {schema_name}.{table_name}
ADD COLUMN IF NOT EXISTS search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('english', {mental_models_text_document()})
) STORED
""")
)
else:
# memory_units writes populate this plain column with the
# configured native language in ops_postgresql.
conn.execute(
text(f"ALTER TABLE {schema_name}.{table_name} ADD COLUMN IF NOT EXISTS search_vector tsvector")
)
# Create GIN index
logger.info(f"Creating GIN index on {table_name}")
conn.execute(
text(f"""
CREATE INDEX idx_{table_name.replace(".", "_")}_text_search
CREATE INDEX IF NOT EXISTS idx_{table_name.replace(".", "_")}_text_search
ON {schema_name}.{table_name}
USING gin(search_vector)
""")
+8 -7
View File
@@ -191,6 +191,7 @@ class Entity(Base):
# Relationships
unit_entities = relationship("UnitEntity", back_populates="entity", cascade="all, delete-orphan")
memory_links = relationship("MemoryLink", back_populates="entity", cascade="all, delete-orphan")
cooccurrences_1 = relationship(
"EntityCooccurrence",
foreign_keys="EntityCooccurrence.entity_id_1",
@@ -260,12 +261,7 @@ class EntityCooccurrence(Base):
class MemoryLink(Base):
"""Links between memory units (temporal, semantic, causal).
Entity edges are not stored here: memory-to-entity associations live in
``unit_entities``, and both the /graph endpoint and recall derive entity
edges from that table on demand.
"""
"""Links between memory units (temporal, semantic, entity)."""
__tablename__ = "memory_links"
@@ -276,24 +272,29 @@ class MemoryLink(Base):
UUID(as_uuid=True), ForeignKey("memory_units.id", ondelete="CASCADE"), primary_key=True
)
link_type: Mapped[str] = mapped_column(Text, primary_key=True)
entity_id: Mapped[PyUUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("entities.id", ondelete="CASCADE"), primary_key=True
)
weight: Mapped[float] = mapped_column(Float, nullable=False, server_default="1.0")
created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
# Relationships
from_unit = relationship("MemoryUnit", foreign_keys=[from_unit_id], back_populates="outgoing_links")
to_unit = relationship("MemoryUnit", foreign_keys=[to_unit_id], back_populates="incoming_links")
entity = relationship("Entity", back_populates="memory_links")
__table_args__ = (
# Retain writes ``caused_by`` only. Keep the historical causal values
# valid so existing rows and transfer archives remain queryable.
CheckConstraint(
"link_type IN ('temporal', 'semantic', 'causes', 'caused_by', 'enables', 'prevents')",
"link_type IN ('temporal', 'semantic', 'entity', 'causes', 'caused_by', 'enables', 'prevents')",
name="memory_links_link_type_check",
),
CheckConstraint("weight >= 0.0 AND weight <= 1.0", name="memory_links_weight_check"),
Index("idx_memory_links_from", "from_unit_id"),
Index("idx_memory_links_to", "to_unit_id"),
Index("idx_memory_links_type", "link_type"),
Index("idx_memory_links_entity", "entity_id", postgresql_where=sql_text("entity_id IS NOT NULL")),
Index(
"idx_memory_links_from_weight",
"from_unit_id",
@@ -1,6 +1,19 @@
from datetime import datetime
def format_task_error(e: BaseException) -> str:
"""Render an exception for a task failure log line / stored error_message.
Always prefixes the exception class. Plenty of exceptions carry an empty
``str()`` ``TimeoutError()``, ``CancelledError()``, a bare ``raise
SomeError()`` and the bare interpolation those log lines used produced
``Task execution failed: graph_maintenance, error: ``, which says nothing at
all about what went wrong (issue #3218).
"""
message = str(e)
return f"{type(e).__name__}: {message}" if message else type(e).__name__
class RetryTaskAt(Exception):
"""Raise from a task handler to schedule a retry at a specific time."""
@@ -58,6 +58,7 @@ def create_worker_app(poller: WorkerPoller, memory):
from fastapi.responses import JSONResponse, Response
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
from ..liveness import WorkerLivenessResponse, worker_liveness_response
from ..metrics import create_metrics_collector, get_metrics_collector, initialize_metrics
app = FastAPI(
@@ -81,20 +82,58 @@ def create_worker_app(poller: WorkerPoller, memory):
metrics_collector.set_db_pool(memory._pool)
logger.info("DB pool metrics configured")
@app.get(
"/health",
summary="Health check endpoint",
description="Returns worker health status including database connectivity",
tags=["Monitoring"],
)
async def health_endpoint():
"""Health check endpoint."""
async def _readiness_response() -> JSONResponse:
"""Shared body of /health and /health/ready: 200 if healthy, 503 if not."""
health = await memory.health_check()
health["worker_id"] = poller.worker_id
health["is_shutdown"] = poller.is_shutdown
status_code = 200 if health.get("status") == "healthy" else 503
return JSONResponse(content=health, status_code=status_code)
@app.get(
"/health",
summary="Health check endpoint",
description="Readiness check: returns worker health status including database "
"connectivity. Alias of /health/ready. Use /health/live for liveness probes.",
tags=["Monitoring"],
)
async def health_endpoint():
"""Health check endpoint."""
return await _readiness_response()
@app.get(
"/health/ready",
summary="Readiness probe",
description="Returns 200 when the worker can reach the database, 503 otherwise. "
"Identical to /health, which stays supported as its alias.",
tags=["Monitoring"],
)
async def readiness_endpoint():
"""Readiness probe that verifies database connectivity."""
return await _readiness_response()
@app.get(
"/health/live",
response_model=WorkerLivenessResponse,
summary="Liveness probe",
description="Returns 200 whenever the worker process can serve a request. Performs "
"no database access, so a slow database never restarts the worker and never "
"requeues its claimed operations. Point livenessProbe here.",
tags=["Monitoring"],
)
async def liveness_endpoint() -> WorkerLivenessResponse:
"""Liveness probe: in-process only, never touches the database.
``seconds_since_last_poll`` exposes poller progress for alerting; it never
changes the status code, because a stalled poll cycle under database
pressure is precisely the case where restarting makes things worse.
"""
return worker_liveness_response(
worker_id=poller.worker_id,
is_shutdown=poller.is_shutdown,
seconds_since_last_poll=poller.seconds_since_last_poll,
)
@app.get(
"/metrics",
summary="Prometheus metrics endpoint",
@@ -13,7 +13,6 @@ import io
import json
import logging
import time
import traceback
from collections import Counter
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass
@@ -22,7 +21,7 @@ from typing import TYPE_CHECKING, Any
from ..config import get_config
from ..engine.schema import fq_table_explicit as fq_table
from ..metrics import get_metrics_collector
from .exceptions import DeferOperation, RetryTaskAt
from .exceptions import DeferOperation, RetryTaskAt, format_task_error
from .stage import StageHolder, bind_holder
# Map DB operation_type -> metric `operation` label, collapsing the retain
@@ -255,6 +254,10 @@ class WorkerPoller:
self._in_flight_lock = asyncio.Lock()
self._last_progress_log = 0.0
self._tasks_completed_since_log = 0
# Monotonic stamp of the last completed claim cycle. Reported by the
# liveness probe so operators can alert on a poller that stopped making
# progress; None until the first cycle finishes.
self._last_poll_at: float | None = None
# Track active tasks locally: operation_id -> ActiveTaskInfo
self._active_tasks: dict[str, ActiveTaskInfo] = {}
# Track in-flight tasks by operation type
@@ -851,10 +854,12 @@ class WorkerPoller:
# Retry is not a terminal outcome — do not record a completion.
await self._schedule_retry(task.operation_id, e.retry_at, str(e), task.schema)
except Exception as e:
logger.error(f"Task {task.operation_id} failed: {e}")
traceback.print_exc()
# exc_info rather than print_exc(): the stderr copy carries no task id
# and is the first thing lost to log rotation (issue #3218).
error_message = format_task_error(e)
logger.error(f"Task {task.operation_id} failed: {error_message}", exc_info=True)
try:
await self._mark_failed(task.operation_id, str(e), task.schema)
await self._mark_failed(task.operation_id, error_message, task.schema)
except Exception:
# Marking a task failed is itself a DB write, and it can fail
# (pool exhausted, connection reset, statement timeout). Without
@@ -1251,6 +1256,7 @@ class WorkerPoller:
try:
# Claim a batch of tasks (respecting slot limits)
tasks = await self.claim_batch()
self._last_poll_at = time.monotonic()
if tasks:
# Log batch info
@@ -1298,8 +1304,7 @@ class WorkerPoller:
logger.info(f"Worker {self._worker_id} polling loop cancelled")
break
except Exception as e:
logger.error(f"Worker {self._worker_id} error in polling loop: {e}")
traceback.print_exc()
logger.error(f"Worker {self._worker_id} error in polling loop: {format_task_error(e)}", exc_info=True)
# Backoff on error
await asyncio.sleep(1)
@@ -1741,3 +1746,10 @@ class WorkerPoller:
def is_shutdown(self) -> bool:
"""Check if shutdown has been signaled."""
return self._shutdown.is_set()
@property
def seconds_since_last_poll(self) -> float | None:
"""Age of the last completed claim cycle, or None before the first one."""
if self._last_poll_at is None:
return None
return round(time.monotonic() - self._last_poll_at, 1)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api-slim"
version = "0.8.6"
version = "0.9.0"
description = "Hindsight: Agent Memory That Works Like Human Memory"
license = "MIT"
readme = "README.md"
@@ -46,21 +46,134 @@ async def test_duplicate_document_ids_rejected_async(memory, request_context):
@pytest.mark.asyncio
async def test_duplicate_document_ids_rejected_sync(memory, request_context):
"""Test that sync retain also rejects batches with duplicate document_ids."""
bank_id = "test_duplicate_sync"
contents = [
{"content": "First item", "document_id": "doc1"},
{"content": "Second item", "document_id": "doc1"}, # Duplicate!
]
async def test_shared_document_id_folds_sync(memory, request_context):
"""Sync retain accepts several items sharing one document_id and folds them
into a single document, in request order (the documented RetainRequest
example see issue #3363)."""
bank_id = f"test_shared_doc_sync_{uuid.uuid4().hex}"
try:
contents = [
{"content": "Alice works at Google", "context": "work", "document_id": "conversation_123"},
{"content": "Bob went hiking yesterday", "document_id": "conversation_123"},
]
# Should raise ValueError due to duplicate document_ids
with pytest.raises(ValueError, match="duplicate document_ids.*doc1"):
await memory.retain_batch_async(
# Must NOT raise (this used to be a 400).
result = await memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
# One result slot per input content is preserved.
assert len(result) == 2
# The items folded into exactly one document.
docs = await memory.list_documents(bank_id=bank_id, request_context=request_context)
assert docs["total"] == 1
assert docs["items"][0]["id"] == "conversation_123"
# Both items' content lands in that one document's body, in order.
doc = await memory.get_document("conversation_123", bank_id, request_context=request_context)
body = doc["original_text"]
assert "Alice works at Google" in body
assert "Bob went hiking yesterday" in body
assert body.index("Alice works at Google") < body.index("Bob went hiking yesterday")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_batch_level_document_id_folds_sync(memory, request_context):
"""The deprecated batch-level document_id (applied to every item without its
own) folds those items into one document instead of tripping the guard."""
bank_id = f"test_batch_doc_id_sync_{uuid.uuid4().hex}"
try:
result = await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Alice works at Google"},
{"content": "Bob loves Python"},
],
document_id="meeting-2024-01-15",
request_context=request_context,
)
assert len(result) == 2
docs = await memory.list_documents(bank_id=bank_id, request_context=request_context)
assert docs["total"] == 1
assert docs["items"][0]["id"] == "meeting-2024-01-15"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_shared_and_distinct_document_ids_sync(memory, request_context):
"""A batch mixing a shared document_id with a distinct one folds only the
shared items, leaving the distinct document on its own."""
bank_id = f"test_mixed_doc_ids_sync_{uuid.uuid4().hex}"
try:
result = await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Alice works at Google", "document_id": "docA"},
{"content": "Bob loves Python", "document_id": "docB"},
{"content": "Alice also mentors interns", "document_id": "docA"},
],
request_context=request_context,
)
assert len(result) == 3
docs = await memory.list_documents(bank_id=bank_id, request_context=request_context)
assert docs["total"] == 2
doc_a = await memory.get_document("docA", bank_id, request_context=request_context)
assert "Alice works at Google" in doc_a["original_text"]
assert "Alice also mentors interns" in doc_a["original_text"]
doc_b = await memory.get_document("docB", bank_id, request_context=request_context)
assert "Bob loves Python" in doc_b["original_text"]
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_shared_document_id_folds_sync_large_batch(memory, request_context):
"""A shared-document batch large enough to exceed the auto-split token
threshold still folds into one document with NO lost content. Splitting one
document across sub-batches would trip the streaming pipeline's content-hash
ownership check and drop later sub-batches, so shared groups take a single
pass this guards that decision (issue #3363)."""
from hindsight_api.engine.memory_engine import count_tokens
bank_id = f"test_shared_doc_large_sync_{uuid.uuid4().hex}"
try:
# Two ~5.5k-token items sharing one document_id → ~11k tokens, over the
# 10k default split threshold. Distinct markers pin each item's presence.
filler = "The quick brown fox jumps over the lazy dog. " * 500
contents = [
{"content": f"MARKER_ALPHA at the start. {filler}", "document_id": "big_conversation"},
{"content": f"{filler} MARKER_OMEGA at the end.", "document_id": "big_conversation"},
]
assert sum(count_tokens(c["content"]) for c in contents) > 10_000
result = await memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
assert len(result) == 2
docs = await memory.list_documents(bank_id=bank_id, request_context=request_context)
assert docs["total"] == 1
# Both items survive — neither sub-batch was dropped by a takeover abort.
doc = await memory.get_document("big_conversation", bank_id, request_context=request_context)
assert "MARKER_ALPHA" in doc["original_text"]
assert "MARKER_OMEGA" in doc["original_text"]
chunks = await memory.list_document_chunks(bank_id, "big_conversation", request_context=request_context)
assert chunks["total"] > 1
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
@@ -0,0 +1,160 @@
"""Tests for type validation of bank config values (write side + read side).
The bank-config API accepted `dict[str, Any]` updates without ever checking the
values against the declared ``HindsightConfig`` field types, so a client could
store a JSON object in a string-typed field such as ``observations_mission``.
The write succeeded; the bank then failed *every* consolidation with
``expected string or bytes-like object, got 'dict'`` raised by ``re.sub`` inside
prompt assembly, deterministically, forever. See issue #3218.
Two halves are covered here:
* write side the value is rejected with a message naming field and type;
* read side a bank already carrying the bad value resolves to something
usable instead of wedging.
"""
import pytest
from hindsight_api.config_resolver import (
_coerce_stored_bank_overrides,
_configurable_field_types,
_validate_config_value_types,
)
from hindsight_api.engine.consolidation.prompts import build_consolidation_input
from hindsight_api.worker.exceptions import format_task_error
# The fields found holding JSON objects in the field report on #3218.
_STRING_FIELDS = (
"observations_mission",
"retain_mission",
"reflect_mission",
"retain_custom_instructions",
)
_BAD_MISSION = {"rules": ["only keep preferences"], "budget": 3}
class TestValidateConfigValueTypes:
def test_every_configurable_field_has_a_type_contract(self):
from hindsight_api.config import HindsightConfig
# A field with no derivable contract would silently accept anything.
assert _configurable_field_types().keys() == HindsightConfig.get_configurable_fields()
def test_no_op_passes(self):
_validate_config_value_types({})
# Non-configurable keys are rejected by the caller, not here.
_validate_config_value_types({"unrelated_field": {"any": "shape"}})
def test_dict_in_string_field_raises(self):
for field in _STRING_FIELDS:
with pytest.raises(ValueError, match=field) as exc:
_validate_config_value_types({field: _BAD_MISSION})
# The message must name the expected and the actual type, so the 400
# tells the caller what to send instead.
assert "must be a string" in str(exc.value)
assert "got dict" in str(exc.value)
def test_valid_values_pass(self):
_validate_config_value_types(
{
"observations_mission": "Keep preferences and skills.",
"retain_chunk_size": 4000,
"enable_observations": True,
"mcp_enabled_tools": ["recall"],
"memory_defense": {"mode": "off"},
"recall_budget_adaptive_low": 0.05,
}
)
def test_none_clears_override(self):
for field in _STRING_FIELDS:
_validate_config_value_types({field: None})
def test_int_accepted_for_float_field(self):
# JSON draws no int/float distinction; a ratio of 1 must not 400.
_validate_config_value_types({"recall_budget_adaptive_high": 1})
def test_bool_rejected_for_numeric_field(self):
# bool is an int subclass and would sneak past a naive isinstance check.
with pytest.raises(ValueError, match="retain_chunk_size"):
_validate_config_value_types({"retain_chunk_size": True})
def test_string_rejected_for_numeric_and_bool_fields(self):
with pytest.raises(ValueError, match="retain_chunk_size"):
_validate_config_value_types({"retain_chunk_size": "4000"})
with pytest.raises(ValueError, match="enable_observations"):
_validate_config_value_types({"enable_observations": "true"})
def test_entity_labels_accepts_both_supported_shapes(self):
# Annotated `list | None`, but parse_entity_labels() also takes the
# {"attributes": [...]} envelope — the contract must not narrow that.
_validate_config_value_types({"entity_labels": []})
_validate_config_value_types({"entity_labels": {"attributes": []}})
with pytest.raises(ValueError, match="entity_labels"):
_validate_config_value_types({"entity_labels": "person"})
class TestCoerceStoredBankOverrides:
def test_clean_overrides_pass_through_unchanged(self):
overrides = {"observations_mission": "Keep preferences.", "retain_chunk_size": 4000}
assert _coerce_stored_bank_overrides("bank1", overrides) == overrides
def test_dict_in_string_field_is_json_encoded(self):
coerced = _coerce_stored_bank_overrides("bank1", {"observations_mission": _BAD_MISSION})
mission = coerced["observations_mission"]
assert isinstance(mission, str)
# Semantics preserved: the structure still reaches the prompt, as text.
assert "only keep preferences" in mission
def test_non_coercible_override_is_dropped(self):
# An int field cannot be salvaged; the bank must fall back to the
# server default rather than resolve to a value nothing can use.
coerced = _coerce_stored_bank_overrides("bank1", {"retain_chunk_size": {"size": 4000}})
assert "retain_chunk_size" not in coerced
def test_coercion_warns_with_the_bank_and_field(self, caplog):
with caplog.at_level("WARNING"):
_coerce_stored_bank_overrides("bank1", {"observations_mission": _BAD_MISSION})
assert "bank1" in caplog.text
assert "observations_mission" in caplog.text
def test_strategy_overrides_are_coerced_too(self):
# apply_strategy() splices these onto the resolved config, so a nested
# bad value wedges the bank exactly as a top-level one does.
coerced = _coerce_stored_bank_overrides(
"bank1",
{"retain_strategies": {"chat": {"retain_mission": _BAD_MISSION, "retain_chunk_size": 4000}}},
)
chat = coerced["retain_strategies"]["chat"]
assert isinstance(chat["retain_mission"], str)
assert chat["retain_chunk_size"] == 4000
def test_strategy_null_override_is_preserved(self):
# Inside a strategy, null is a deliberate override to None (unlike a
# top-level null, which is the "use the server default" tombstone).
coerced = _coerce_stored_bank_overrides(
"bank1", {"retain_strategies": {"chat": {"retain_structured_chunk_size": None}}}
)
assert coerced["retain_strategies"]["chat"] == {"retain_structured_chunk_size": None}
def test_coerced_mission_survives_prompt_assembly(self):
"""The exact reported failure: a dict mission reaching prompt assembly."""
with pytest.raises(TypeError, match="expected string or bytes-like object"):
build_consolidation_input("facts", "observations", observations_mission=_BAD_MISSION)
coerced = _coerce_stored_bank_overrides("bank1", {"observations_mission": _BAD_MISSION})
prompt = build_consolidation_input(
"facts", "observations", observations_mission=coerced["observations_mission"]
)
assert "only keep preferences" in prompt
class TestFormatTaskError:
def test_message_is_prefixed_with_the_exception_class(self):
assert format_task_error(ValueError("boom")) == "ValueError: boom"
def test_empty_message_still_identifies_the_exception(self):
# The reported `Task execution failed: graph_maintenance, error: ` case.
assert format_task_error(TimeoutError()) == "TimeoutError"
@@ -0,0 +1,222 @@
"""Every exportable bank field survives a full export -> import round-trip.
The existing coverage leaves a gap in the middle:
* ``test_bank_template_configurable_fields`` sets fields **one at a time** and
never exports it proves import writes a field, not that export reads it back.
* ``TestExport::test_export_reimport_roundtrip`` does export then import, but
with a single config field, and it asserts only the response flags
(``config_applied is True``) never that any value survived.
So a field that import accepts but export drops (or that export reshapes into
something import rejects) passes both. This module closes that: it sets *every*
field ``BankTemplateConfig`` declares on one bank, exports it, imports the
exported manifest into a fresh bank, and asserts the second bank's overrides
match the first's.
``BankTemplateConfig.model_fields`` is the exportable surface the export
endpoint filters bank overrides through exactly that set so the sample table
below is checked against it. A newly added template field fails
``test_sample_values_cover_every_exportable_field`` until it gets a value here,
which is the point: the round-trip must not silently stop covering it.
Adding a per-bank config field is a multi-step flow, and each step here fails
until the previous one is done so a half-wired field cannot land quietly:
1. add it to ``_CONFIGURABLE_FIELDS`` ``test_every_configurable_field_is_exportable``
fails until it is declared on ``BankTemplateConfig``;
2. declare it there ``test_sample_values_cover_every_exportable_field`` fails
until it has a value in ``_SAMPLE_VALUES``;
3. give it a value the round-trip below actually exercises it end to end;
4. changing ``BankTemplateConfig`` also moves the OpenAPI spec, the generated
clients and ``bank-template-schema.json``, so CI's ``verify-generated-files``
fails until those are regenerated.
(``test_bank_config_value_types`` adds a fifth: every configurable field must
have a derivable type contract.) See #3218 for what a half-wired config field
costs in production.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
import httpx
import pytest
import pytest_asyncio
from hindsight_api.api import create_app
from hindsight_api.api.http import BankTemplateConfig
from hindsight_api.config import HindsightConfig
# One value per BankTemplateConfig field, each chosen to differ visibly from the
# server default so a value that silently reverts is caught rather than matching
# by luck. Cross-field constraints enforced by validate_bank_template() and the
# config validators are respected:
# * retain_custom_instructions requires retain_extraction_mode == "custom"
# * retain_default_strategy names a key in retain_strategies
# * recall_budget_min <= recall_budget_max
_SAMPLE_VALUES: dict[str, Any] = {
"reflect_mission": "Answer as a careful archivist.",
"retain_mission": "Keep only decisions and the reasoning behind them.",
"retain_extraction_mode": "custom",
"retain_custom_instructions": "Extract one fact per decision, dated.",
"retain_chunk_size": 2500,
"retain_structured_chunk_size": 1800,
"enable_observations": False,
"observations_mission": "Observations cover preferences and skills only.",
"enable_temporal_retrieval": False,
"enable_graph_retrieval": False,
"enable_reranking": False,
"disposition_skepticism": 4,
"disposition_literalism": 2,
"disposition_empathy": 5,
"entity_labels": [
{
"key": "team",
"description": "Owning team",
"type": "value",
"optional": False,
"tag": True,
"values": [
{"value": "platform", "description": "Platform team"},
{"value": "growth", "description": "Growth team"},
],
}
],
"entities_allow_free_form": False,
"retain_default_strategy": "meetings",
"retain_strategies": {"meetings": {"retain_chunk_size": 1200, "retain_extraction_mode": "verbose"}},
"retain_chunk_batch_size": 7,
"mcp_enabled_tools": ["recall", "retain"],
"consolidation_llm_batch_size": 11,
"consolidation_source_facts_max_tokens": 2048,
"consolidation_source_facts_max_tokens_per_observation": 256,
"max_observations_per_scope": 13,
"observation_scope_limits": [{"scope": ["run_*"], "limit": 2}],
"reflect_source_facts_max_tokens": 4096,
"llm_gemini_safety_settings": [{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}],
"recall_budget_function": "adaptive",
"recall_budget_fixed_low": 50,
"recall_budget_fixed_mid": 250,
"recall_budget_fixed_high": 800,
"recall_budget_adaptive_low": 0.05,
"recall_budget_adaptive_mid": 0.1,
"recall_budget_adaptive_high": 0.4,
"recall_budget_min": 30,
"recall_budget_max": 1500,
"audit_log_enabled": True,
"store_document_text": False,
"enable_auto_consolidation": False,
"consolidation_max_memories_per_round": 42,
"consolidation_llm_parallelism": 3,
"recall_include_chunks": True,
"recall_max_tokens": 9000,
"recall_chunks_max_tokens": 4500,
# Validated against the DefensePolicy schema on write (parse_policy), so this
# must be a real policy — and carrying a rule means the round-trip covers the
# nested list, not just the top-level flag.
"memory_defense": {"enabled": True, "rules": [{"on": "sensitive_data", "action": "redact"}]},
}
@pytest_asyncio.fixture
async def api_client(memory):
"""In-process ASGI client — matches the fixture in tests/test_bank_templates.py."""
app = create_app(memory, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
@pytest.fixture
def bank_id():
return f"tmpl_roundtrip_{datetime.now().timestamp()}"
async def _read_overrides(api_client: httpx.AsyncClient, bank_id: str) -> dict[str, Any]:
"""Per-bank overrides only — the resolved config would hide a dropped field
behind the server default and make the round-trip look successful."""
resp = await api_client.get(f"/v1/default/banks/{bank_id}/config")
assert resp.status_code == 200, resp.text
return resp.json()["overrides"]
def test_every_configurable_field_is_exportable():
"""Every per-bank config field must be part of the template engine.
A field in ``_CONFIGURABLE_FIELDS`` but not on ``BankTemplateConfig`` is
settable per bank yet invisible to export/import: cloning a bank silently
drops it, and the clone runs on the server default while looking correctly
configured. The two sets must match exactly an intentional exclusion is a
decision to record here, in an explicit set with a reason, not an omission.
"""
configurable = HindsightConfig.get_configurable_fields()
exportable = set(BankTemplateConfig.model_fields)
assert configurable == exportable, (
f"bank config and the template engine have drifted:\n"
f" configurable but not exportable (add to BankTemplateConfig): "
f"{sorted(configurable - exportable)}\n"
f" exportable but not configurable (remove, or add to _CONFIGURABLE_FIELDS): "
f"{sorted(exportable - configurable)}"
)
def test_sample_values_cover_every_exportable_field():
"""A new BankTemplateConfig field must be given a value in _SAMPLE_VALUES.
Guards the round-trip test below from quietly narrowing: without this, adding
a template field leaves it untested and nothing says so.
"""
declared = set(BankTemplateConfig.model_fields)
sampled = set(_SAMPLE_VALUES)
assert declared == sampled, (
f"_SAMPLE_VALUES is out of sync with BankTemplateConfig: "
f"missing values for {sorted(declared - sampled)}, "
f"stale entries for {sorted(sampled - declared)}"
)
@pytest.mark.asyncio
async def test_every_exportable_field_survives_export_then_import(api_client, bank_id):
"""Set every exportable field, export it, and import into a fresh bank."""
source_id = f"{bank_id}_source"
clone_id = f"{bank_id}_clone"
resp = await api_client.post(
f"/v1/default/banks/{source_id}/import",
json={"version": "1", "bank": dict(_SAMPLE_VALUES)},
)
assert resp.status_code == 200, resp.text
assert resp.json()["config_applied"] is True
# The source bank must actually carry all of them before the round-trip can
# prove anything — a field the import path drops would otherwise show up as
# a clean "match" between two equally empty banks.
source_overrides = await _read_overrides(api_client, source_id)
missing_after_import = sorted(set(_SAMPLE_VALUES) - set(source_overrides))
assert not missing_after_import, f"import did not persist: {missing_after_import}"
export_resp = await api_client.get(f"/v1/default/banks/{source_id}/export")
assert export_resp.status_code == 200, export_resp.text
exported = export_resp.json()
exported_bank = exported.get("bank") or {}
dropped_by_export = sorted(f for f in _SAMPLE_VALUES if exported_bank.get(f) is None)
assert not dropped_by_export, f"export dropped fields that were set: {dropped_by_export}"
import_resp = await api_client.post(f"/v1/default/banks/{clone_id}/import", json=exported)
assert import_resp.status_code == 200, import_resp.text
assert import_resp.json()["config_applied"] is True
# Compare the two banks' overrides rather than the literal input: some fields
# are normalized on the way in (entity_labels migrates legacy shapes), and the
# property under test is that a clone ends up configured like its source.
clone_overrides = await _read_overrides(api_client, clone_id)
for field in sorted(_SAMPLE_VALUES):
assert clone_overrides.get(field) == source_overrides.get(field), (
f"round-trip mismatch for {field}: "
f"source has {source_overrides.get(field)!r}, clone has {clone_overrides.get(field)!r}"
)
@@ -0,0 +1,127 @@
"""Per-bank vector-index DDL is serialized per table within a process.
Concurrent index DDL on the shared ``memory_units`` table deadlocks by design:
DROP INDEX CONCURRENTLY holds ShareUpdateExclusive while waiting out every
other session whose snapshot could still see the index including other
sessions' queued index DDL. CI's end-of-run teardown (all xdist workers
deleting their banks at once) forms exactly that cycle, and the delete path's
default retry budget (~2.4s) could not outlast the storm (run 31195108586,
test-api 3/3). Advisory locks are banned in this codebase (poolers), so the
fix is an in-process asyncio lock on ``PostgreSQLOps`` plus a much larger
jittered retry budget for the cross-process residue.
Two layers are proven here:
* unit (fake conn): create and drop DDL for one table never interleave, the
create and drop sides contend on the same lock, and the lock is released
when a statement raises;
* integration: a many-bank concurrent ``delete_bank`` storm the CI failure
shape completes without ``DeadlockDetectedError``.
"""
import asyncio
import uuid
import pytest
from hindsight_api.engine.db.ops_postgresql import PostgreSQLOps
from hindsight_api.engine.retain.bank_utils import _BANK_INDEX_FACT_TYPES, _bank_index_name, _vector_index_clause
_SCHEMA = "public"
_INDEX_CLAUSE = "USING hnsw (embedding vector_cosine_ops)"
class _OverlapTrackingConn:
"""Fake DatabaseConnection asserting no two DDL statements ever overlap.
Each execute parks on the event loop long enough that unserialized callers
would interleave, and records the highest number of in-flight statements.
"""
def __init__(self, fail_on: str | None = None):
self.calls: list[str] = []
self.max_in_flight = 0
self._in_flight = 0
self._fail_on = fail_on
async def execute(self, query, *args, **kwargs):
self.calls.append(query)
self._in_flight += 1
self.max_in_flight = max(self.max_in_flight, self._in_flight)
try:
await asyncio.sleep(0.01)
if self._fail_on and self._fail_on in query:
raise RuntimeError(f"boom on: {query}")
finally:
self._in_flight -= 1
return "OK"
async def test_concurrent_create_and_drop_on_one_table_serialize():
ops = PostgreSQLOps()
conn = _OverlapTrackingConn()
table = f"{_SCHEMA}.memory_units"
await asyncio.gather(
ops.drop_bank_vector_indexes(conn, _SCHEMA, uuid.uuid4().hex, _BANK_INDEX_FACT_TYPES),
ops.drop_bank_vector_indexes(conn, _SCHEMA, uuid.uuid4().hex, _BANK_INDEX_FACT_TYPES),
# The drop side reconstructs the create side's fq-table key from
# `schema`, so a create must queue behind the drops too.
ops.create_bank_vector_indexes(conn, table, "bank-1", uuid.uuid4().hex, _INDEX_CLAUSE, _BANK_INDEX_FACT_TYPES),
)
assert len(conn.calls) == 3 * len(_BANK_INDEX_FACT_TYPES)
assert conn.max_in_flight == 1, "vector-index DDL statements overlapped despite the per-table lock"
async def test_different_tables_do_not_contend():
ops = PostgreSQLOps()
assert ops._index_ddl_lock("a.memory_units") is ops._index_ddl_lock("a.memory_units")
assert ops._index_ddl_lock("a.memory_units") is not ops._index_ddl_lock("b.memory_units")
async def test_lock_released_when_ddl_raises():
ops = PostgreSQLOps()
conn = _OverlapTrackingConn(fail_on="DROP INDEX CONCURRENTLY")
with pytest.raises(RuntimeError):
await ops.drop_bank_vector_indexes(conn, _SCHEMA, uuid.uuid4().hex, _BANK_INDEX_FACT_TYPES)
# A subsequent create must not hang on a lock the failed drop never released.
await asyncio.wait_for(
ops.create_bank_vector_indexes(
conn, f"{_SCHEMA}.memory_units", "bank-1", uuid.uuid4().hex, _INDEX_CLAUSE, _BANK_INDEX_FACT_TYPES
),
timeout=2.0,
)
async def test_concurrent_bank_delete_storm_does_not_deadlock(memory, request_context):
"""The CI failure shape: every worker tears down its banks at once.
Eight banks (24 partial indexes) dropped concurrently previously wedged
into a DROP INDEX CONCURRENTLY wait cycle; serialized behind the per-table
lock the storm must complete without DeadlockDetectedError.
"""
if _vector_index_clause() is None:
pytest.skip("backend does not use per-bank vector indexes")
bank_ids = [f"test-ddl-lock-{uuid.uuid4().hex[:8]}" for _ in range(8)]
for bank_id in bank_ids:
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
backend = await memory._get_backend()
async with backend.acquire() as conn:
internal_ids = {
bank_id: str(await conn.fetchval("SELECT internal_id FROM banks WHERE bank_id = $1", bank_id))
for bank_id in bank_ids
}
await asyncio.gather(*(memory.delete_bank(bank_id, request_context=request_context) for bank_id in bank_ids))
async with backend.acquire() as conn:
for bank_id in bank_ids:
for ft in _BANK_INDEX_FACT_TYPES:
idx = _bank_index_name(ft, internal_ids[bank_id])
assert not await conn.fetchval(
"SELECT 1 FROM pg_indexes WHERE schemaname = $1 AND indexname = $2", _SCHEMA, idx
), f"index {idx} survived delete_bank"
+110 -3
View File
@@ -1,6 +1,7 @@
"""Test automatic batch chunking based on character count."""
import asyncio
import json
import os
import pytest
@@ -11,6 +12,7 @@ from hindsight_api.engine.memory_engine import (
_split_contents_into_sub_batches,
count_tokens,
)
from hindsight_api.engine.retain.fact_extraction import chunk_text
# ---------------------------------------------------------------------------
# Regression tests for issue #1571: the splitter must actually chunk an
@@ -31,6 +33,7 @@ def test_split_single_oversized_item_produces_multiple_sub_batches():
split = _split_contents_into_sub_batches(
[{"content": big_content, "document_id": "doc-oversize"}],
tokens_per_batch,
chunk_size=3000,
)
assert len(split.sub_batches) > 1, (
@@ -59,7 +62,7 @@ def test_split_oversized_item_preserves_document_id_and_metadata():
"tags": ["t1", "t2"],
}
split = _split_contents_into_sub_batches([item], tokens_per_batch)
split = _split_contents_into_sub_batches([item], tokens_per_batch, chunk_size=3000)
assert len(split.sub_batches) > 1
for batch in split.sub_batches:
@@ -85,7 +88,7 @@ def test_split_mixed_batch_chunks_only_oversized_items():
{"content": small_b, "document_id": "doc-c"},
]
split = _split_contents_into_sub_batches(contents, tokens_per_batch)
split = _split_contents_into_sub_batches(contents, tokens_per_batch, chunk_size=3000)
# We expect: [small_a packed] then N chunks of big, then [small_b packed].
# At minimum: > 2 sub-batches (a + multiple big chunks + c).
@@ -104,6 +107,110 @@ def test_split_mixed_batch_chunks_only_oversized_items():
assert big_origin_count > small_a_origin_count
def _conversation_payload(turns: int = 120) -> str:
return json.dumps(
[{"role": "user" if i % 2 == 0 else "assistant", "content": f"turn {i} " + "blah " * 60} for i in range(turns)]
)
def _jsonl_payload(lines: int = 200) -> str:
return "\n".join(json.dumps({"i": i, "text": "line " + "x " * 80}) for i in range(lines))
@pytest.mark.parametrize(
"body",
[
pytest.param(
"\n\n".join(f"Section {i}. " + f"marker{i} filler word here. " * 117 for i in range(10)), id="prose"
),
pytest.param(_conversation_payload(), id="json-conversation"),
pytest.param(_jsonl_payload(), id="jsonl"),
],
)
def test_split_slices_are_whole_native_chunks(body):
"""Every slice holds a whole number of native chunks and re-chunks back to
exactly those chunks (issue #3282).
This is the invariant the rest of the retain path is built on: the chunks
stored for a document must depend only on its body, never on how transport
split it. Delta retain and the streaming recovery pass both match stored
chunks by content hash, so a slice cutting mid-chunk silently re-extracts
unchanged history; ``chunk_index`` bookkeeping assumes whole chunks too.
"""
chunk_size = 3000
native_chunks = chunk_text(body, chunk_size)
assert len(native_chunks) > 3, "test payload must span several native chunks"
split = _split_contents_into_sub_batches(
[{"content": body, "document_id": "doc-align"}],
tokens_per_batch=1_500,
chunk_size=chunk_size,
)
assert len(split.sub_batches) > 1, "payload should have been sliced"
rechunked: list[str] = []
for sub_batch, count in zip(split.sub_batches, split.chunk_counts):
slice_chunks = chunk_text(sub_batch[0]["content"], chunk_size)
assert len(slice_chunks) == count, "chunk_counts must match what the slice re-chunks to"
rechunked.extend(slice_chunks)
assert rechunked == native_chunks, "slices did not reproduce the document's native chunks"
def test_split_falls_back_to_one_chunk_per_slice_when_no_faithful_join_exists(monkeypatch):
"""When a run of chunks cannot be rejoined faithfully, each chunk ships alone.
Packing several chunks into one slice is only safe while the joined text
re-chunks back to exactly those chunks. If no candidate join does (a
content shape whose chunker is not reconstructible from its own output),
the splitter must degrade to one chunk per sub-batch trivially aligned by
``chunk_text``'s idempotency — rather than ship a slice whose boundaries
disagree with what gets stored (issue #3282).
"""
from hindsight_api.engine.retain import fact_extraction
# Over the budget so it takes the oversized-item path; the stubbed chunks
# are small enough that the packer wants all three in a single slice.
body = "unjoinable body. " * 500
chunks = ["chunk one", "chunk two", "chunk three"]
def _fake_chunk_text(text, max_chars, structured_chunk_size=None):
# Only the original body chunks into `chunks`; every rejoin attempt
# comes back as something else, so verification always fails.
return list(chunks) if text == body else ["something else entirely"]
monkeypatch.setattr(fact_extraction, "chunk_text", _fake_chunk_text)
split = _split_contents_into_sub_batches(
[{"content": body, "document_id": "doc-unjoinable"}],
tokens_per_batch=100,
chunk_size=3000,
)
assert [b[0]["content"] for b in split.sub_batches] == chunks
assert split.chunk_counts == [1, 1, 1]
def test_split_slice_never_cuts_a_native_chunk_under_a_tiny_budget():
"""A budget below one native chunk cannot shrink the slice past that chunk.
``retain_chunk_size`` is the real bound on a slice; honouring a smaller
token budget would mean cutting mid-chunk, which is exactly what breaks
hash-based chunk reuse (issue #3282).
"""
body = "The quick brown fox jumps over the lazy dog. " * 1_000
native_chunks = chunk_text(body, 3000)
split = _split_contents_into_sub_batches(
[{"content": body, "document_id": "doc-tiny-budget"}],
tokens_per_batch=10,
chunk_size=3000,
)
assert [b[0]["content"] for b in split.sub_batches] == native_chunks
assert split.chunk_counts == [1] * len(native_chunks)
def test_split_small_batch_returns_single_sub_batch():
"""A batch under the budget stays as a single sub-batch."""
tokens_per_batch = 10_000
@@ -112,7 +219,7 @@ def test_split_small_batch_returns_single_sub_batch():
{"content": "Bob loves Python", "document_id": "doc-2"},
]
split = _split_contents_into_sub_batches(contents, tokens_per_batch)
split = _split_contents_into_sub_batches(contents, tokens_per_batch, chunk_size=3000)
assert len(split.sub_batches) == 1
assert split.sub_batches[0] == contents
@@ -0,0 +1,630 @@
"""Tests for backend prompt-cache affinity on the OpenAI-compatible provider family.
Covers the `cache_affinity` knob end to end: mode parsing and `auto` host
resolution, the affinity id (operation trace identity, first-message hash
fallback), injection into both the plain and tool-calling call paths with
user-wins semantics, and the config plumbing that carries the setting from env
to the wire.
The decisive test here is `test_end_to_end_*`: direct-construction tests cannot
catch a break between config and the provider, which is exactly how a first
version of this feature compiled, tested green, and sent nothing on the reflect
lane.
All deterministic no network, mocked clients only.
"""
import re
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from hindsight_api.engine.cache_affinity import (
OPENAI_PROMPT_CACHE_KEY_PARAM,
XAI_CONV_ID_HEADER,
CacheAffinityMode,
apply_cache_affinity,
cache_affinity_id,
parse_cache_affinity,
resolve_cache_affinity,
)
from hindsight_api.engine.llm_trace import LLMTraceContext, reset_trace_context, set_trace_context
from hindsight_api.engine.llm_wrapper import LLMProvider, create_llm_provider
from hindsight_api.engine.providers.nous_auth import NousAuthManager
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
_HEX32 = re.compile(r"\A[0-9a-f]{32}\Z")
# ── helpers ───────────────────────────────────────────────────────────────────
def _llm(cache_affinity: str | None = None, **kwargs) -> OpenAICompatibleLLM:
kwargs.setdefault("base_url", "https://example.test/v1")
kwargs.setdefault("provider", "openai")
kwargs.setdefault("api_key", "test-key")
kwargs.setdefault("model", "gpt-4o-mini")
return OpenAICompatibleLLM(cache_affinity=cache_affinity, **kwargs)
class _FakeNousAuth:
"""Minimal `NousAuthManager` stand-in: a fresh token, refresh never needed.
Mirrors the fake in `test_nous_provider.py` but trimmed to what construction
and a no-401 `call()` touch `access_token`, `base_url`, `_token_is_stale()`.
"""
def __init__(self, token: str = "tok-1", base_url: str = "https://inference-api.nousresearch.com/v1"):
self.access_token = token
self.base_url = base_url
def _token_is_stale(self) -> bool:
return False
def _nous_llm(cache_affinity: str | None = None, **kwargs):
"""Construct a NousLLM through the real `create_llm_provider` factory — the
exact path that silently dropped `cache_affinity`/`default_headers` with
Nous's ~/.hermes/auth.json read stubbed out so the test needs no real login."""
kwargs.setdefault("base_url", "https://inference-api.nousresearch.com/v1")
kwargs.setdefault("api_key", "ignored")
kwargs.setdefault("model", "deepseek/deepseek-v4-flash")
kwargs.setdefault("reasoning_effort", "low")
with patch.object(NousAuthManager, "from_file", return_value=_FakeNousAuth()):
return create_llm_provider(provider="nous", cache_affinity=cache_affinity, **kwargs)
def _chat_response(content: str = "hello"):
choice = SimpleNamespace(
finish_reason="stop",
message=SimpleNamespace(content=content, tool_calls=None, refusal=None, reasoning_content=None),
)
return SimpleNamespace(choices=[choice], usage=None, error=None)
async def _call(llm: OpenAICompatibleLLM, create: AsyncMock, **kwargs):
"""Drive `call()` against a mocked client, returning the mock for assertions."""
llm._client.chat.completions.create = create
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
await llm.call(messages=[{"role": "user", "content": "ping"}], max_retries=0, **kwargs)
return create
async def _call_with_tools(llm: OpenAICompatibleLLM, create: AsyncMock, **kwargs):
"""Drive `call_with_tools()` against a mocked client."""
llm._client.chat.completions.create = create
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
await llm.call_with_tools(
messages=[{"role": "user", "content": "ping"}],
tools=[{"type": "function", "function": {"name": "noop", "parameters": {}}}],
max_retries=0,
**kwargs,
)
return create
@contextmanager
def _bound_trace(trace_id: str):
"""Bind an operation trace context, as `ConfiguredLLMProvider` does per call."""
token = set_trace_context(LLMTraceContext(bank_id="bank-1", operation="reflect", trace_id=trace_id))
try:
yield
finally:
reset_trace_context(token)
# ── AC1: xai_conv_id header ───────────────────────────────────────────────────
async def test_xai_header_is_32_lowercase_hex():
llm = _llm("xai_conv_id")
create = await _call(llm, AsyncMock(return_value=_chat_response()))
header = create.call_args.kwargs["extra_headers"][XAI_CONV_ID_HEADER]
assert _HEX32.match(header)
async def test_xai_header_is_stable_within_one_trace_context():
"""Every LLM call of one reflect/retain run must pin to the same backend."""
llm = _llm("xai_conv_id")
create = AsyncMock(return_value=_chat_response())
with _bound_trace("11111111-1111-1111-1111-111111111111"):
await _call(llm, create)
first = create.call_args.kwargs["extra_headers"][XAI_CONV_ID_HEADER]
# Different message content: the id comes from operation identity, not payload.
llm._client.chat.completions.create = create
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
await llm.call(messages=[{"role": "user", "content": "a different prompt"}], max_retries=0)
second = create.call_args.kwargs["extra_headers"][XAI_CONV_ID_HEADER]
assert first == second
async def test_xai_header_differs_across_trace_contexts():
llm = _llm("xai_conv_id")
create = AsyncMock(return_value=_chat_response())
with _bound_trace("11111111-1111-1111-1111-111111111111"):
await _call(llm, create)
first = create.call_args.kwargs["extra_headers"][XAI_CONV_ID_HEADER]
with _bound_trace("22222222-2222-2222-2222-222222222222"):
await _call(llm, create)
second = create.call_args.kwargs["extra_headers"][XAI_CONV_ID_HEADER]
assert first != second
# ── AC2: openai_prompt_cache_key ──────────────────────────────────────────────
async def test_openai_prompt_cache_key_is_sent():
"""`prompt_cache_key` is a first-class named param on chat.completions.create,
so it is sent at the top level rather than through extra_body."""
llm = _llm("openai_prompt_cache_key")
create = await _call(llm, AsyncMock(return_value=_chat_response()))
assert _HEX32.match(create.call_args.kwargs[OPENAI_PROMPT_CACHE_KEY_PARAM])
async def test_openai_prompt_cache_key_coexists_with_extra_body():
"""The provider's own extra_body defaults and the operator's configured
extra_body must both survive the affinity hint merges, never replaces."""
llm = _llm(
"openai_prompt_cache_key",
provider="minimax",
base_url="https://api.minimax.io/v1",
model="MiniMax-M3",
extra_body={"top_p": 0.9},
)
create = await _call(llm, AsyncMock(return_value=_chat_response()))
kwargs = create.call_args.kwargs
assert _HEX32.match(kwargs[OPENAI_PROMPT_CACHE_KEY_PARAM])
assert kwargs["extra_body"]["thinking"] == {"type": "disabled"} # minimax default
assert kwargs["extra_body"]["top_p"] == 0.9 # operator config
# ── AC3: default / none is byte-compatible with a pre-affinity request ────────
@pytest.mark.parametrize("mode", [None, "none"])
async def test_no_affinity_key_by_default(mode):
llm = _llm(mode)
create = await _call(llm, AsyncMock(return_value=_chat_response()))
kwargs = create.call_args.kwargs
assert "extra_headers" not in kwargs
assert OPENAI_PROMPT_CACHE_KEY_PARAM not in kwargs
assert XAI_CONV_ID_HEADER not in str(kwargs)
def test_invalid_mode_raises():
"""The setting has no visible effect in the response, so a typo that silently
disabled it would be indistinguishable from it working."""
with pytest.raises(ValueError, match="Invalid cache_affinity"):
_llm("xai-conv-id")
# ── AC4: auto resolution table ────────────────────────────────────────────────
@pytest.mark.parametrize(
("provider", "base_url", "expected"),
[
("openai", "https://api.x.ai/v1", CacheAffinityMode.XAI_CONV_ID),
("openai", "https://cli-chat-proxy.grok.com/v1", CacheAffinityMode.XAI_CONV_ID),
("openai", None, CacheAffinityMode.OPENAI_PROMPT_CACHE_KEY),
("openai", "", CacheAffinityMode.OPENAI_PROMPT_CACHE_KEY),
("openai", "https://api.openai.com/v1", CacheAffinityMode.OPENAI_PROMPT_CACHE_KEY),
("openai", "https://my-res.openai.azure.com/openai/deployments/x", CacheAffinityMode.OPENAI_PROMPT_CACHE_KEY),
("fireworks", "https://api.fireworks.ai/inference/v1", CacheAffinityMode.NONE),
("fireworks", "", CacheAffinityMode.NONE),
("openai", "https://llm.internal.example/v1", CacheAffinityMode.NONE),
],
)
def test_auto_resolution_table(provider, base_url, expected):
assert resolve_cache_affinity(CacheAffinityMode.AUTO, provider, base_url) is expected
@pytest.mark.parametrize("base_url", ["https://api.vertex.ai/v1", "https://x.ai.evil.example/v1"])
def test_auto_does_not_substring_match_xai(base_url):
"""`"x.ai" in base_url` would false-match both of these; the host is parsed."""
assert resolve_cache_affinity(CacheAffinityMode.AUTO, "openai", base_url) is CacheAffinityMode.NONE
def test_auto_resolves_once_at_construction():
llm = _llm("auto", base_url="https://api.x.ai/v1")
assert llm._cache_affinity is CacheAffinityMode.XAI_CONV_ID
@pytest.mark.parametrize("mode", ["none", "xai_conv_id", "openai_prompt_cache_key"])
def test_explicit_modes_are_not_re_resolved(mode):
parsed = parse_cache_affinity(mode)
assert resolve_cache_affinity(parsed, "openai", "https://api.x.ai/v1") is parsed
# ── AC5: no-context fallback (first-message hash) ─────────────────────────────
def test_fallback_hashes_the_first_message():
messages = [{"role": "system", "content": "you are helpful"}, {"role": "user", "content": "hi"}]
assert _HEX32.match(cache_affinity_id(messages))
def test_fallback_is_stable_as_the_message_list_grows():
"""The agent loop appends turns; the pin must not rotate mid-conversation."""
base = [{"role": "system", "content": "you are helpful"}]
grown = [*base, {"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}]
assert cache_affinity_id(base) == cache_affinity_id(grown)
def test_fallback_differs_for_a_different_first_message():
a = cache_affinity_id([{"role": "system", "content": "prompt A"}])
b = cache_affinity_id([{"role": "system", "content": "prompt B"}])
assert a != b
@pytest.mark.parametrize("messages", [None, [], "bogus", ["not a dict"], [42]])
def test_fallback_returns_none_for_malformed_messages(messages):
"""A bare string would index to its first character and mint an id from
garbage; anything unexpected sends no hint at all."""
assert cache_affinity_id(messages) is None
async def test_no_header_when_messages_are_malformed():
"""Fail-open all the way to the wire: no id means no header, not an error."""
request = {"messages": "bogus"}
apply_cache_affinity(request, CacheAffinityMode.XAI_CONV_ID)
assert request == {"messages": "bogus"}
async def test_call_uses_the_fallback_hash_without_a_trace_context():
llm = _llm("xai_conv_id")
create = await _call(llm, AsyncMock(return_value=_chat_response()))
sent = create.call_args.kwargs["extra_headers"][XAI_CONV_ID_HEADER]
assert sent == cache_affinity_id(create.call_args.kwargs["messages"])
# ── AC6: call_with_tools parity ───────────────────────────────────────────────
async def test_tools_path_sends_xai_header():
llm = _llm("xai_conv_id")
create = await _call_with_tools(llm, AsyncMock(return_value=_chat_response("done")))
assert _HEX32.match(create.call_args.kwargs["extra_headers"][XAI_CONV_ID_HEADER])
async def test_tools_path_sends_prompt_cache_key():
llm = _llm("openai_prompt_cache_key")
create = await _call_with_tools(llm, AsyncMock(return_value=_chat_response("done")))
assert _HEX32.match(create.call_args.kwargs[OPENAI_PROMPT_CACHE_KEY_PARAM])
async def test_tools_path_sends_nothing_by_default():
llm = _llm()
create = await _call_with_tools(llm, AsyncMock(return_value=_chat_response("done")))
kwargs = create.call_args.kwargs
assert "extra_headers" not in kwargs
assert OPENAI_PROMPT_CACHE_KEY_PARAM not in kwargs
# ── AC13: fallback hash on the tools path ─────────────────────────────────────
async def test_tools_path_fallback_hash_is_stable():
"""`call_with_tools` builds its message list differently; its fallback id must
still derive from the first message and survive a growing conversation."""
llm = _llm("xai_conv_id")
create = AsyncMock(return_value=_chat_response("done"))
llm._client.chat.completions.create = create
tools = [{"type": "function", "function": {"name": "noop", "parameters": {}}}]
system = {"role": "system", "content": "you are helpful"}
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
await llm.call_with_tools(messages=[system], tools=tools, max_retries=0)
first = create.call_args.kwargs["extra_headers"][XAI_CONV_ID_HEADER]
await llm.call_with_tools(
messages=[system, {"role": "user", "content": "and now?"}], tools=tools, max_retries=0
)
second = create.call_args.kwargs["extra_headers"][XAI_CONV_ID_HEADER]
assert _HEX32.match(first)
assert first == second
# ── AC7: adjacent fix — default_headers reaches the SDK client ────────────────
def test_default_headers_reach_the_openai_compatible_client():
"""`HINDSIGHT_API_LLM_DEFAULT_HEADERS` used to no-op for all 15 providers on
this branch: the factory accepted the field but never forwarded it."""
llm = create_llm_provider(
provider="openai",
api_key="test-key",
base_url="https://example.test/v1",
model="gpt-4o-mini",
reasoning_effort="low",
default_headers={"x-test": "1"},
)
assert llm._client.default_headers["x-test"] == "1"
def test_default_headers_reach_the_fireworks_client():
"""Fireworks subclasses OpenAICompatibleLLM and had the same gap."""
llm = create_llm_provider(
provider="fireworks",
api_key="test-key",
base_url="https://api.fireworks.ai/inference/v1",
model="accounts/fireworks/models/llama-v3p1-8b-instruct",
reasoning_effort="low",
default_headers={"x-test": "2"},
)
assert llm._client.default_headers["x-test"] == "2"
def test_fireworks_branch_forwards_cache_affinity():
llm = create_llm_provider(
provider="fireworks",
api_key="test-key",
base_url="https://api.fireworks.ai/inference/v1",
model="accounts/fireworks/models/llama-v3p1-8b-instruct",
reasoning_effort="low",
cache_affinity="xai_conv_id",
)
assert llm._cache_affinity is CacheAffinityMode.XAI_CONV_ID
def test_default_headers_reach_the_nous_client():
"""NousLLM subclasses OpenAICompatibleLLM and had the same gap: the `nous`
factory branch forwarded neither `default_headers` nor `cache_affinity`,
even though `NousLLM.__init__` already passes both through **kwargs."""
llm = _nous_llm(default_headers={"x-test": "3"})
assert llm._client.default_headers["x-test"] == "3"
async def test_nous_branch_forwards_cache_affinity():
"""AC7-style factory-branch check, taken all the way to the wire (AC1 style)
rather than stopping at `_cache_affinity`, since the missing kwarg here is a
silent no-op the same failure shape AC10's end-to-end test exists for."""
llm = _nous_llm(cache_affinity="xai_conv_id")
create = await _call(llm, AsyncMock(return_value=_chat_response()))
header = create.call_args.kwargs["extra_headers"][XAI_CONV_ID_HEADER]
assert _HEX32.match(header)
# ── AC8: config plumbing (env round-trip) ─────────────────────────────────────
@pytest.fixture
def clean_llm_env(monkeypatch):
"""Strip all HINDSIGHT_API_*LLM* env so each test sets only what it needs."""
import os
from hindsight_api.config import clear_config_cache
for key in list(os.environ):
if key.startswith("HINDSIGHT_API_") and "LLM" in key:
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "openai")
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "sk-primary")
monkeypatch.setenv("HINDSIGHT_API_SKIP_LLM_VERIFICATION", "true")
clear_config_cache()
yield monkeypatch
clear_config_cache()
def test_config_reads_global_and_per_operation_affinity(clean_llm_env):
from hindsight_api.config import HindsightConfig
clean_llm_env.setenv("HINDSIGHT_API_LLM_CACHE_AFFINITY", "auto")
clean_llm_env.setenv("HINDSIGHT_API_REFLECT_LLM_CACHE_AFFINITY", "xai_conv_id")
config = HindsightConfig.from_env()
assert config.llm_cache_affinity == "auto"
assert config.reflect_llm_cache_affinity == "xai_conv_id"
assert config.retain_llm_cache_affinity is None
assert config.consolidation_llm_cache_affinity is None
def test_config_affinity_defaults_to_auto(clean_llm_env):
"""Unset means "auto", not "off".
"auto" only emits a hint for hosts documented to accept one (see
test_auto_default_sends_nothing_to_an_unrecognized_backend), so defaulting
it on costs unknown backends nothing while every xAI/OpenAI deployment gets
the cache hit it was otherwise silently losing.
"""
from hindsight_api.config import HindsightConfig
assert HindsightConfig.from_env().llm_cache_affinity == "auto"
def test_llm_provider_from_env_defaults_to_auto(clean_llm_env):
"""The two env entry points must agree; they resolved differently before."""
clean_llm_env.setenv("HINDSIGHT_API_LLM_PROVIDER", "openai")
clean_llm_env.setenv("HINDSIGHT_API_LLM_API_KEY", "sk-test")
assert LLMProvider.from_env().cache_affinity == "auto"
@pytest.mark.parametrize(
("provider", "base_url"),
[
("openai", "https://my-proxy.internal/v1"),
("openai", "http://localhost:8000/v1"),
("ollama", "http://localhost:11434/v1"),
("groq", "https://api.groq.com/openai/v1"),
("deepseek", "https://api.deepseek.com"),
("openrouter", "https://openrouter.ai/api/v1"),
("lmstudio", "http://localhost:1234/v1"),
("", "https://vllm.internal/v1"),
],
)
def test_auto_default_sends_nothing_to_an_unrecognized_backend(provider, base_url):
"""The safety property that makes "auto" viable as a default.
An OpenAI-compatible backend that never documented either mechanism must
receive a byte-identical request. "auto" is an allowlist, so anything off it
resolves to none rather than being probed with an unfamiliar field.
"""
assert resolve_cache_affinity(CacheAffinityMode.AUTO, provider, base_url) is CacheAffinityMode.NONE
@pytest.mark.parametrize(
("provider", "base_url", "expected"),
[
("openai", "https://api.x.ai/v1", CacheAffinityMode.XAI_CONV_ID),
("openai", "https://grok.com/v1", CacheAffinityMode.XAI_CONV_ID),
("openai", None, CacheAffinityMode.OPENAI_PROMPT_CACHE_KEY),
("openai", "https://api.openai.com/v1", CacheAffinityMode.OPENAI_PROMPT_CACHE_KEY),
("openai", "https://myco.openai.azure.com/", CacheAffinityMode.OPENAI_PROMPT_CACHE_KEY),
],
)
def test_auto_default_sends_a_hint_only_to_documented_hosts(provider, base_url, expected):
assert resolve_cache_affinity(CacheAffinityMode.AUTO, provider, base_url) is expected
def test_indexed_member_carries_affinity(clean_llm_env):
from hindsight_api.config import _parse_llm_members
clean_llm_env.setenv("HINDSIGHT_API_REFLECT_LLM_1_PROVIDER", "openai")
clean_llm_env.setenv("HINDSIGHT_API_REFLECT_LLM_1_API_KEY", "sk-member")
clean_llm_env.setenv("HINDSIGHT_API_REFLECT_LLM_1_CACHE_AFFINITY", "xai_conv_id")
members = _parse_llm_members("REFLECT_")
assert [m.cache_affinity for m in members] == ["xai_conv_id"]
assert _parse_llm_members("RETAIN_") == []
def test_llm_provider_from_env_carries_affinity(clean_llm_env):
clean_llm_env.setenv("HINDSIGHT_API_LLM_CACHE_AFFINITY", "xai_conv_id")
clean_llm_env.setenv("HINDSIGHT_API_LLM_BASE_URL", "https://api.x.ai/v1")
llm = LLMProvider.from_env()
assert llm._provider_impl._cache_affinity is CacheAffinityMode.XAI_CONV_ID
# ── AC9: bank attribution is unaffected ───────────────────────────────────────
async def test_bank_attribution_and_affinity_coexist(clean_llm_env):
from hindsight_api.config import clear_config_cache
from hindsight_api.engine.memory_engine import _current_bank_id
clean_llm_env.setenv("HINDSIGHT_API_LLM_SEND_BANK_AS_USER", "true")
clear_config_cache()
llm = _llm("xai_conv_id")
create = AsyncMock(return_value=_chat_response())
token = _current_bank_id.set("user-9")
try:
await _call(llm, create)
finally:
_current_bank_id.reset(token)
assert create.call_args.kwargs["user"] == "user-9"
assert _HEX32.match(create.call_args.kwargs["extra_headers"][XAI_CONV_ID_HEADER])
# ── AC11: user-configured values win ──────────────────────────────────────────
async def test_configured_prompt_cache_key_survives():
"""The operator's escape hatch: an explicit prompt_cache_key in extra_body
reaches the same wire field, so ours is suppressed rather than duplicated."""
llm = _llm("openai_prompt_cache_key", extra_body={"prompt_cache_key": "operator-value"})
create = await _call(llm, AsyncMock(return_value=_chat_response()))
kwargs = create.call_args.kwargs
assert kwargs["extra_body"]["prompt_cache_key"] == "operator-value"
assert OPENAI_PROMPT_CACHE_KEY_PARAM not in kwargs
def test_preset_conv_id_header_is_not_clobbered():
"""Mirrors `apply_bank_attribution`'s "never override an explicit value" rule."""
request = {
"messages": [{"role": "user", "content": "ping"}],
"extra_headers": {XAI_CONV_ID_HEADER: "caller-pinned"},
}
apply_cache_affinity(request, CacheAffinityMode.XAI_CONV_ID)
assert request["extra_headers"][XAI_CONV_ID_HEADER] == "caller-pinned"
def test_other_extra_headers_are_preserved():
request = {"messages": [{"role": "user", "content": "ping"}], "extra_headers": {"x-other": "keep"}}
apply_cache_affinity(request, CacheAffinityMode.XAI_CONV_ID)
assert request["extra_headers"]["x-other"] == "keep"
assert _HEX32.match(request["extra_headers"][XAI_CONV_ID_HEADER])
# ── AC12: the hint survives a retry ───────────────────────────────────────────
async def test_affinity_persists_across_a_retry():
"""`call_params` is built once before the retry loop; attempt 2 must carry it."""
llm = _llm("xai_conv_id")
# Empty content raises a retryable ProviderResponseError on attempt 1.
create = AsyncMock(side_effect=[_chat_response(content=""), _chat_response()])
llm._client.chat.completions.create = create
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
await llm.call(
messages=[{"role": "user", "content": "ping"}],
max_retries=1,
initial_backoff=0.0,
)
assert create.await_count == 2
for call in create.await_args_list:
assert _HEX32.match(call.kwargs["extra_headers"][XAI_CONV_ID_HEADER])
# ── AC10: END-TO-END anti-inert ───────────────────────────────────────────────
async def test_end_to_end_reflect_lane_sends_the_affinity_header(clean_llm_env):
"""The decisive test. Builds the reflect provider through the PRODUCTION path
env -> HindsightConfig -> MemoryEngine's reflect base build -> LLMProvider
-> create_llm_provider -> OpenAICompatibleLLM -> with_config() -> the wire
and asserts the header lands on the request.
A direct-construction test passes even when nothing connects config to the
provider, which is exactly how the first version of this feature shipped
inert for the reflect lane. This one fails in that state.
"""
from hindsight_api import MemoryEngine
clean_llm_env.setenv("HINDSIGHT_API_LLM_MODEL", "grok-4.5")
clean_llm_env.setenv("HINDSIGHT_API_REFLECT_LLM_BASE_URL", "https://api.x.ai/v1")
clean_llm_env.setenv("HINDSIGHT_API_REFLECT_LLM_CACHE_AFFINITY", "xai_conv_id")
engine = MemoryEngine(skip_llm_verification=True)
reflect_llm = engine._reflect_llm_config
create = AsyncMock(return_value=_chat_response())
reflect_llm._provider_impl._client.chat.completions.create = create
configured = reflect_llm.with_config(
SimpleNamespace(llm_gemini_safety_settings=None), bank_id="bank-e2e", operation="reflect"
)
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
await configured.call(messages=[{"role": "user", "content": "ping"}], max_retries=0)
assert _HEX32.match(create.call_args.kwargs["extra_headers"][XAI_CONV_ID_HEADER])
async def test_end_to_end_failover_member_sends_the_affinity_header(clean_llm_env):
"""Same production path for an indexed chain member, which is built by
`_member_to_llm` rather than the per-operation base build. Without that leg,
a failover member would silently drop the pin the primary carries."""
from hindsight_api import MemoryEngine
from hindsight_api.engine.multi_llm import MultiLLMProvider
clean_llm_env.setenv("HINDSIGHT_API_LLM_MODEL", "grok-4.5")
clean_llm_env.setenv("HINDSIGHT_API_LLM_CACHE_AFFINITY", "xai_conv_id")
clean_llm_env.setenv("HINDSIGHT_API_REFLECT_LLM_1_PROVIDER", "openai")
clean_llm_env.setenv("HINDSIGHT_API_REFLECT_LLM_1_API_KEY", "sk-member")
clean_llm_env.setenv("HINDSIGHT_API_REFLECT_LLM_1_MODEL", "grok-4.5-fallback")
clean_llm_env.setenv("HINDSIGHT_API_REFLECT_LLM_1_BASE_URL", "https://api.x.ai/v1")
clean_llm_env.setenv("HINDSIGHT_API_REFLECT_LLM_STRATEGY", '{"mode": "failover"}')
engine = MemoryEngine(skip_llm_verification=True)
chain = engine._reflect_llm_config
assert isinstance(chain, MultiLLMProvider)
member = chain._members[1]
assert member.model == "grok-4.5-fallback"
create = AsyncMock(return_value=_chat_response())
member._provider_impl._client.chat.completions.create = create
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
await member.call(messages=[{"role": "user", "content": "ping"}], max_retries=0)
assert _HEX32.match(create.call_args.kwargs["extra_headers"][XAI_CONV_ID_HEADER])
@@ -0,0 +1,75 @@
"""Regression tests for Codex extra request-body parameters."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from hindsight_api.engine.llm_wrapper import create_llm_provider
from hindsight_api.engine.providers.codex_llm import CodexLLM
def build_llm(extra_body: dict | None = None) -> CodexLLM:
with (
patch.object(CodexLLM, "_load_codex_auth", return_value=("token", "account")),
patch.object(CodexLLM, "_load_codex_refresh_token", return_value=None),
):
return CodexLLM(
provider="openai-codex",
api_key="ignored",
base_url="https://chatgpt.com/backend-api",
model="gpt-5.6-luna",
extra_body=extra_body,
)
def test_factory_forwards_extra_body() -> None:
with (
patch.object(CodexLLM, "_load_codex_auth", return_value=("token", "account")),
patch.object(CodexLLM, "_load_codex_refresh_token", return_value=None),
):
llm = create_llm_provider(
provider="openai-codex",
api_key="ignored",
base_url="https://chatgpt.com/backend-api",
model="gpt-5.6-luna",
reasoning_effort="low",
extra_body={"service_tier": "priority"},
)
assert llm._extra_body == {"service_tier": "priority"}
@pytest.mark.asyncio
async def test_call_merges_extra_body_into_request() -> None:
llm = build_llm({"service_tier": "priority"})
response = MagicMock()
response.raise_for_status.return_value = None
with (
patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post,
patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock, return_value="ok"),
):
mock_post.return_value = response
await llm.call(messages=[{"role": "user", "content": "hello"}], max_retries=0)
assert mock_post.call_args.kwargs["json"]["service_tier"] == "priority"
@pytest.mark.asyncio
async def test_call_with_tools_merges_extra_body_into_request() -> None:
llm = build_llm({"service_tier": "priority"})
response = MagicMock(status_code=200)
response.raise_for_status.return_value = None
with (
patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post,
patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock, return_value=(None, [])),
):
mock_post.return_value = response
await llm.call_with_tools(
messages=[{"role": "user", "content": "hello"}],
tools=[],
max_retries=0,
)
assert mock_post.call_args.kwargs["json"]["service_tier"] == "priority"
@@ -0,0 +1,262 @@
"""
Regression tests for https://github.com/vectorize-io/hindsight/issues/3282
When a replacement body for an existing ``document_id`` exceeds
``retain_batch_tokens``, ``retain_batch_async`` slices it into sub-batches
*before* the orchestrator gets a chance to classify the whole replacement
against the stored chunks. Delta retain only runs on the first sub-batch (and
only sees that slice), so a one-section edit plus an appended tail re-extracts
the entire unchanged history instead of just the changed/new native chunks.
The control test pins the behaviour with a transport budget large enough to
keep the replacement in one piece (delta works there today); the repro test
lowers only ``retain_batch_tokens`` and asserts the same document edit still
skips the unchanged chunks.
"""
from datetime import datetime, timezone
import pytest
from hindsight_api.config import clear_config_cache
from hindsight_api.engine.memory_engine import count_tokens
from hindsight_api.engine.retain import fact_extraction, orchestrator
# Sections are separated by blank lines and each is just under
# retain_chunk_size (3000 chars), so the chunker emits exactly one native chunk
# per section. That keeps the chunk boundaries stable across versions: the edit
# below is length-preserving, so only the edited section's chunk changes.
_SECTION_REPEATS = 117 # ~2.5 KB per section
_BASE_SECTIONS = 10
_APPENDED_SECTIONS = 3
# Transport budget for the replacement retain. The splitter slices an oversized
# item at ``3 * retain_batch_tokens`` chars, so this is deliberately below the
# native chunk size: the slices cut across native chunk boundaries.
_OVERSIZED_BATCH_TOKENS = 300
def _ts() -> float:
return datetime.now(timezone.utc).timestamp()
def _section(idx: int, *, edited: bool = False) -> str:
marker = f"MARKER{idx:02d}"
payload = "bbbbb" if edited else "aaaaa"
return f"Section {idx:02d} {marker}. Payload {payload}. " + f"{marker} filler word here. " * _SECTION_REPEATS
def _body(*, edited_idx: int | None = None, appended: int = 0) -> str:
sections = [_section(i, edited=(i == edited_idx)) for i in range(_BASE_SECTIONS)]
sections += [_section(100 + j) for j in range(appended)]
return "\n\n".join(sections)
def _unchanged_markers(edited_idx: int) -> list[str]:
return [f"MARKER{i:02d}" for i in range(_BASE_SECTIONS) if i != edited_idx]
class _ExtractionSpy:
"""Records the content fed to LLM fact extraction on each call."""
def __init__(self) -> None:
self.texts: list[str] = []
def install(self, monkeypatch) -> None:
original = fact_extraction.extract_facts_from_contents
async def _spy(contents, *args, **kwargs):
self.texts.extend(c.content for c in contents)
return await original(contents, *args, **kwargs)
monkeypatch.setattr(fact_extraction, "extract_facts_from_contents", _spy)
def markers_seen(self, markers: list[str]) -> list[str]:
blob = "\n".join(self.texts)
return [m for m in markers if m in blob]
@property
def extracted_tokens(self) -> int:
return sum(count_tokens(t) for t in self.texts)
@pytest.fixture(autouse=True)
def _fast_retain_env(monkeypatch):
# Keep the tests focused on the retain path.
monkeypatch.setenv("HINDSIGHT_API_ENABLE_AUTO_CONSOLIDATION", "false")
monkeypatch.setenv("HINDSIGHT_API_ENABLE_OBSERVATIONS", "false")
clear_config_cache()
yield
clear_config_cache()
async def _retain_v1_then_v2(
memory,
request_context,
bank_id: str,
document_id: str,
*,
replacement_batch_tokens: int | None,
monkeypatch,
spy: _ExtractionSpy,
edited_idx: int,
) -> str:
"""Retain the base body, then the edited+appended replacement.
``replacement_batch_tokens`` is applied only to the second retain, matching
the issue's reproduction steps. Returns the replacement body.
"""
v1 = _body()
await memory.retain_async(
bank_id=bank_id,
content=v1,
context="notes",
document_id=document_id,
request_context=request_context,
)
v2 = _body(edited_idx=edited_idx, appended=_APPENDED_SECTIONS)
if replacement_batch_tokens is not None:
monkeypatch.setenv("HINDSIGHT_API_RETAIN_BATCH_TOKENS", str(replacement_batch_tokens))
clear_config_cache()
# Only spy on the replacement — the first retain legitimately extracts everything.
spy.install(monkeypatch)
await memory.retain_async(
bank_id=bank_id,
content=v2,
context="notes",
document_id=document_id,
request_context=request_context,
)
return v2
@pytest.mark.asyncio
async def test_replacement_within_batch_budget_skips_unchanged_chunks(memory, request_context, monkeypatch):
"""Control: with the whole replacement inside the transport budget, delta
retain re-extracts only the edited section and the appended tail."""
bank_id = f"test_3282_control_{_ts()}"
document_id = "doc-3282-control"
edited_idx = 1
spy = _ExtractionSpy()
try:
await _retain_v1_then_v2(
memory,
request_context,
bank_id,
document_id,
replacement_batch_tokens=100_000, # whole replacement fits — no splitting
monkeypatch=monkeypatch,
spy=spy,
edited_idx=edited_idx,
)
re_extracted = spy.markers_seen(_unchanged_markers(edited_idx))
assert re_extracted == [], f"unchanged sections were re-extracted: {re_extracted}"
assert spy.markers_seen([f"MARKER{edited_idx:02d}", "MARKER100"]), (
"the edited section and the appended tail should have been extracted"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_oversized_replacement_still_skips_unchanged_chunks(memory, request_context, monkeypatch):
"""Repro for #3282: the same edit must not re-extract unchanged history just
because the complete replacement exceeds ``retain_batch_tokens``.
The budget is set below both the full replacement and the aggregate token
count of the changed/new chunks, so the transport split cannot be satisfied
by the delta chunk list either delta classification must still run against
the complete body first.
"""
bank_id = f"test_3282_oversized_{_ts()}"
document_id = "doc-3282-oversized"
edited_idx = 1
spy = _ExtractionSpy()
batch_tokens = _OVERSIZED_BATCH_TOKENS
try:
v2 = await _retain_v1_then_v2(
memory,
request_context,
bank_id,
document_id,
replacement_batch_tokens=batch_tokens,
monkeypatch=monkeypatch,
spy=spy,
edited_idx=edited_idx,
)
# Sanity-check the premise of the repro: the replacement really is over
# the transport budget, and so is the changed/new chunk aggregate.
assert count_tokens(v2) > batch_tokens
changed_chunk_tokens = count_tokens(_section(edited_idx, edited=True)) + sum(
count_tokens(_section(100 + j)) for j in range(_APPENDED_SECTIONS)
)
assert changed_chunk_tokens > batch_tokens
re_extracted = spy.markers_seen(_unchanged_markers(edited_idx))
assert re_extracted == [], (
f"unchanged sections {re_extracted} were re-extracted: the oversized "
f"replacement bypassed delta retain (issue #3282). Extraction saw "
f"{spy.extracted_tokens:,} tokens; only the changed/new chunks "
f"(~{changed_chunk_tokens:,} tokens) should have reached it."
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_oversized_replacement_screens_document_body_once(memory, request_context, monkeypatch):
"""Companion to #3282: the split fallback must not re-run Memory Defense over
the whole document for every sub-batch.
Each sub-batch carries ``document_body_override`` (the COMPLETE body, so
``documents.original_text`` isn't clobbered with a slice), and the retain
path redaction-scans that override before persisting it. With N sub-batches
the full body is scanned N times even though only one of them wins the
document row.
"""
bank_id = f"test_3282_defense_{_ts()}"
document_id = "doc-3282-defense"
edited_idx = 1
spy = _ExtractionSpy()
full_body_scans: list[int] = []
original_redaction = orchestrator.apply_redaction
def _counting_redaction(text: str, *args, **kwargs):
full_body_scans.append(len(text))
return original_redaction(text, *args, **kwargs)
try:
await memory.update_bank_config(
bank_id,
{"memory_defense": {"enabled": True, "rules": [{"on": "sensitive_data", "action": "redact"}]}},
request_context=request_context,
)
monkeypatch.setattr(orchestrator, "apply_redaction", _counting_redaction)
v2 = await _retain_v1_then_v2(
memory,
request_context,
bank_id,
document_id,
replacement_batch_tokens=_OVERSIZED_BATCH_TOKENS,
monkeypatch=monkeypatch,
spy=spy,
edited_idx=edited_idx,
)
replacement_scans = [n for n in full_body_scans if n == len(v2)]
assert len(replacement_scans) <= 1, (
f"the complete {len(v2):,}-char body was Memory Defense scanned "
f"{len(replacement_scans)} times — once per fallback sub-batch (issue #3282)"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -0,0 +1,266 @@
"""End-to-end regression for issue #3294: delta retain must not orphan observations.
The reporter's sequence, driven through the public engine API rather than by calling
the storage helpers directly:
retain(document) -> consolidate -> retain(same document_id, edited) -> consolidate
Before the fix, the second retain took the delta path, which deletes the changed
chunks and lets the FK cascade drop their facts with no observation sweep in
between. The observations derived from those facts stayed behind, still valid and
still recallable, pointing at ``source_memory_ids`` that no longer resolved. Nothing
could reach them afterwards: consolidation batches are built from facts, so an
observation whose sources are all gone is never selected into a batch again.
These tests assert the invariant the lifecycle documents ("removing a document: all
observations derived from the document's memories are deleted") on the paths delta
retain actually takes: an edit, a removal, and a no-op re-ingest. A rewrite of *every*
chunk is deliberately not covered here with no unchanged chunk left, delta declines
and the full-replace path (already covered in ``test_observation_invalidation.py``)
handles it.
"""
import uuid
import pytest
from hindsight_api import RequestContext
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.memories import FactRecord, get_memories
from hindsight_api.engine.memory_engine import MemoryEngine, fq_table
# Delta retain works per chunk, so the document has to be big enough to produce several
# (chunk size is 3000 chars) and at least one of them must come out unchanged — with no
# unchanged chunk delta declines and falls back to a full replace. Keeping the FIRST
# block byte-identical across a re-ingest is what guarantees that: chunking is greedy
# from the start of the text, so an edit after chunk 0's boundary cannot move it.
_BLOCK_A = " ".join(
f"Alice shipped the Alpha{i} milestone at Google in the search infrastructure group." for i in range(40)
)
_BLOCK_B = " ".join(f"Bob reviewed the Beta{i} rollout at Microsoft in the Azure networking group." for i in range(40))
_BLOCK_B_EDITED = " ".join(
f"Bob reviewed the Beta{i} rollout at Amazon in the AWS networking group." for i in range(40)
)
_DOCUMENT_V1 = f"{_BLOCK_A} {_BLOCK_B}"
_DOCUMENT_V2_PARTIAL_EDIT = f"{_BLOCK_A} {_BLOCK_B_EDITED}"
@pytest.fixture(autouse=True)
def enable_observations():
config = _get_raw_config()
original = config.enable_observations
config.enable_observations = True
yield
config.enable_observations = original
async def _scan(memory: MemoryEngine, bank_id: str, fact_types: list[str]) -> list[FactRecord]:
"""Every stored memory of these types, read through whichever store holds them."""
store = get_memories()
pool = await memory._get_pool()
async with pool.acquire() as conn:
page = await store.scan_memories(
conn=conn,
fq_table=fq_table,
bank_id=bank_id,
fact_types=fact_types,
limit=1_000_000,
)
return list(page.memories)
async def _facts(memory: MemoryEngine, bank_id: str) -> list[FactRecord]:
return await _scan(memory, bank_id, ["experience", "world"])
async def _observations(memory: MemoryEngine, bank_id: str) -> list[FactRecord]:
return await _scan(memory, bank_id, ["observation"])
def _broken_source_refs(observations: list[FactRecord], live_fact_ids: set[str]) -> list[tuple[str, list[str]]]:
"""The reporter's diagnostic: observations whose sources no longer resolve.
Returns ``(observation_id, unresolvable_source_ids)`` per affected row what the
bug report counted as "broken references" on their bank.
"""
broken = []
for obs in observations:
missing = [sid for sid in obs.source_memory_ids if sid not in live_fact_ids]
if missing:
broken.append((obs.unit_id, missing))
return broken
async def _assert_no_orphans(memory: MemoryEngine, bank_id: str, when: str) -> None:
facts = await _facts(memory, bank_id)
observations = await _observations(memory, bank_id)
broken = _broken_source_refs(observations, {f.unit_id for f in facts})
assert broken == [], (
f"{when}: {len(broken)} of {len(observations)} observation(s) reference deleted source "
f"memories (issue #3294 — delta retain cascaded the facts away without sweeping the "
f"observations derived from them): {broken[:5]}"
)
async def _retain_document(
memory: MemoryEngine, bank_id: str, document_id: str, content: str, request_context: RequestContext
) -> None:
await memory.retain_async(
bank_id=bank_id,
content=content,
context="team roster",
document_id=document_id,
request_context=request_context,
)
def _facts_by_chunk(facts: list[FactRecord]) -> dict[str, set[str]]:
by_chunk: dict[str, set[str]] = {}
for fact in facts:
if fact.chunk_id:
by_chunk.setdefault(fact.chunk_id, set()).add(fact.unit_id)
return by_chunk
@pytest.mark.asyncio
async def test_delta_retain_partial_edit_leaves_no_orphan_observations(
memory: MemoryEngine, request_context: RequestContext
):
"""Editing the tail of a consolidated document orphans nothing.
Also pins the precision of the sweep: the untouched first chunk keeps its facts
AND the observations derived only from them, so a small edit does not
re-consolidate the whole document the case delta retain exists for.
"""
bank_id = f"test_delta_orphan_partial_{uuid.uuid4().hex[:8]}"
document_id = "roster-doc"
try:
await _retain_document(memory, bank_id, document_id, _DOCUMENT_V1, request_context)
await memory.run_consolidation(bank_id=bank_id, request_context=request_context)
facts_v1 = await _facts(memory, bank_id)
observations_v1 = await _observations(memory, bank_id)
by_chunk_v1 = _facts_by_chunk(facts_v1)
assert len(by_chunk_v1) >= 2, f"Setup: the document should span several chunks, got {list(by_chunk_v1)}"
assert observations_v1, "Setup: consolidation should have produced observations to orphan"
await _assert_no_orphans(memory, bank_id, "after the first retain")
first_chunk = sorted(by_chunk_v1)[0]
kept_fact_ids = by_chunk_v1[first_chunk]
edited_fact_ids = {fid for chunk, ids in by_chunk_v1.items() if chunk != first_chunk for fid in ids}
assert kept_fact_ids and edited_fact_ids
obs_over_edited = {o.unit_id for o in observations_v1 if edited_fact_ids.intersection(o.source_memory_ids)}
obs_only_over_kept = {
o.unit_id
for o in observations_v1
if o.source_memory_ids and set(o.source_memory_ids).issubset(kept_fact_ids)
}
assert obs_over_edited, "Setup: the chunks being edited should have observations derived from them"
assert obs_only_over_kept, "Setup: the unchanged chunk should have observations of its own"
# Re-ingest with only the tail changed — this is the delta path.
await _retain_document(memory, bank_id, document_id, _DOCUMENT_V2_PARTIAL_EDIT, request_context)
surviving_fact_ids = {f.unit_id for f in await _facts(memory, bank_id)}
# Delta really applied: the unchanged chunk's facts were preserved rather than
# re-extracted under new ids (a full replace would have changed all of them).
assert kept_fact_ids.issubset(surviving_fact_ids), (
"Unchanged chunk's facts should survive the delta re-ingest — if they did not, this "
"test fell back to the full-replace path and no longer covers the bug"
)
assert not edited_fact_ids.intersection(surviving_fact_ids), "The edited chunks' facts should be gone"
await _assert_no_orphans(memory, bank_id, "after the delta re-ingest")
observation_ids_v2 = {o.unit_id for o in await _observations(memory, bank_id)}
assert not observation_ids_v2.intersection(obs_over_edited), (
"Observations derived from the edited chunks' facts should have been invalidated"
)
assert obs_only_over_kept.issubset(observation_ids_v2), (
"Observations derived only from the unchanged chunk must survive a partial edit"
)
# And the follow-up consolidation the reporter ran — still no orphans.
await memory.run_consolidation(bank_id=bank_id, request_context=request_context)
await _assert_no_orphans(memory, bank_id, "after re-consolidating the edited document")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_removed_chunks_leave_no_orphan_observations(
memory: MemoryEngine, request_context: RequestContext
):
"""Shortening a document orphans nothing either.
Delta deletes removed chunks through the same call as changed ones, so this
covers the ``removed_indices`` half of that list a document that shrinks loses
facts without any replacement being extracted for them.
"""
bank_id = f"test_delta_orphan_shrink_{uuid.uuid4().hex[:8]}"
document_id = "roster-doc"
try:
await _retain_document(memory, bank_id, document_id, _DOCUMENT_V1, request_context)
await memory.run_consolidation(bank_id=bank_id, request_context=request_context)
facts_v1 = await _facts(memory, bank_id)
observations_v1 = await _observations(memory, bank_id)
by_chunk_v1 = _facts_by_chunk(facts_v1)
assert len(by_chunk_v1) >= 2, f"Setup: the document should span several chunks, got {list(by_chunk_v1)}"
assert observations_v1, "Setup: consolidation should have produced observations to orphan"
first_chunk = sorted(by_chunk_v1)[0]
kept_fact_ids = by_chunk_v1[first_chunk]
dropped_fact_ids = {fid for chunk, ids in by_chunk_v1.items() if chunk != first_chunk for fid in ids}
obs_over_dropped = {o.unit_id for o in observations_v1 if dropped_fact_ids.intersection(o.source_memory_ids)}
assert obs_over_dropped, "Setup: the chunks being dropped should have observations derived from them"
# Re-ingest only the first block: every later chunk is removed outright.
await _retain_document(memory, bank_id, document_id, _BLOCK_A, request_context)
surviving_fact_ids = {f.unit_id for f in await _facts(memory, bank_id)}
assert kept_fact_ids.issubset(surviving_fact_ids), (
"The retained chunk's facts should survive — if they did not, this test fell back "
"to the full-replace path and no longer covers the bug"
)
assert not dropped_fact_ids.intersection(surviving_fact_ids), "The removed chunks' facts should be gone"
await _assert_no_orphans(memory, bank_id, "after shrinking the document")
assert not {o.unit_id for o in await _observations(memory, bank_id)}.intersection(obs_over_dropped), (
"Observations derived from the removed chunks' facts should have been invalidated"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_delta_retain_unchanged_content_keeps_observations(memory: MemoryEngine, request_context: RequestContext):
"""Re-submitting identical content deletes no chunk, so it sweeps no observation
and requeues nothing for consolidation."""
bank_id = f"test_delta_orphan_noop_{uuid.uuid4().hex[:8]}"
document_id = "roster-doc"
try:
await _retain_document(memory, bank_id, document_id, _DOCUMENT_V1, request_context)
await memory.run_consolidation(bank_id=bank_id, request_context=request_context)
observation_ids_v1 = {o.unit_id for o in await _observations(memory, bank_id)}
assert observation_ids_v1
await _retain_document(memory, bank_id, document_id, _DOCUMENT_V1, request_context)
assert {o.unit_id for o in await _observations(memory, bank_id)} == observation_ids_v1, (
"A no-op delta re-ingest must not touch existing observations"
)
assert all(f.consolidated_at is not None for f in await _facts(memory, bank_id)), (
"A no-op delta re-ingest must not requeue facts for consolidation"
)
await _assert_no_orphans(memory, bank_id, "after a no-op re-ingest")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -100,6 +100,20 @@ async def _import(memory, bank_id, archive, request_context, on_conflict="skip")
return status["result_metadata"]
async def _export_async(memory, bank_id, request_context, **kwargs):
"""Submit an async export and return (result_metadata, archive_bytes).
Export is async; the SyncTaskBackend fixture runs it inline, so the operation
is completed (with the archive stashed in file storage) when submit returns.
"""
submission = await memory.submit_export_documents_async(bank_id, request_context, **kwargs)
status = await memory.get_operation_status(bank_id, submission["operation_id"], request_context=request_context)
assert status["status"] == "completed", status
meta = status["result_metadata"]
archive = await memory._file_storage.retrieve(meta["storage_key"])
return meta, archive
@pytest.mark.asyncio
async def test_import_filters_degenerate_fact_without_shifting_archive_ordinals(memory, request_context):
"""A rejected archive fact must not shift chunks, causal links, or observation sources."""
@@ -191,12 +205,13 @@ def test_export_bank_covers_schema():
history, or explicitly skipped so a future migration can't silently drop one."""
from hindsight_api.admin.cli import BACKUP_TABLES
from hindsight_api.engine.transfer.export import _BANK_ROW_TABLES, _REPLAYED_TABLES, _SKIP_TABLES
from hindsight_api.engine.transfer.schema import CARRIED_HISTORY_TABLES, HISTORY_TABLES
from hindsight_api.engine.transfer.schema import CARRIED_HISTORY_TABLES, HISTORY_TABLES, KNOWLEDGE_TABLES
buckets = [
set(_REPLAYED_TABLES),
set(_BANK_ROW_TABLES),
set(CARRIED_HISTORY_TABLES),
set(KNOWLEDGE_TABLES),
set(HISTORY_TABLES),
set(_SKIP_TABLES),
]
@@ -209,6 +224,38 @@ def test_export_bank_covers_schema():
assert sum(len(b) for b in buckets) == len(classified), "a table is classified in more than one bucket"
def test_topological_page_order_is_parent_first():
"""Nodes always sort so a parent precedes its children (self-FK safe)."""
from hindsight_api.engine.transfer.importer import _topological_page_order
from hindsight_api.engine.transfer.schema import TransferKnowledgePage
def _page(pid, parent):
kind = "page" if pid.startswith("p") else "folder"
return TransferKnowledgePage(id=pid, parent_id=parent, kind=kind, name=pid)
# Deliberately shuffled: child before parent, grandchild before both.
pages = [_page("pC", "fB"), _page("fB", "fA"), _page("fA", None), _page("pRoot", None)]
ordered = [p.id for p in _topological_page_order(pages)]
assert ordered.index("fA") < ordered.index("fB") < ordered.index("pC")
assert ordered.index("fA") < ordered.index("pC")
assert set(ordered) == {"pC", "fB", "fA", "pRoot"}
def test_topological_page_order_tolerates_cycles_and_dangling_parents():
"""A cycle or missing parent (only possible in a corrupt export) is emitted
rather than dropped, so the DB FK not a silent loss surfaces it."""
from hindsight_api.engine.transfer.importer import _topological_page_order
from hindsight_api.engine.transfer.schema import TransferKnowledgePage
cycle = [
TransferKnowledgePage(id="a", parent_id="b", kind="folder", name="a"),
TransferKnowledgePage(id="b", parent_id="a", kind="folder", name="b"),
]
assert {p.id for p in _topological_page_order(cycle)} == {"a", "b"}
dangling = [TransferKnowledgePage(id="x", parent_id="missing", kind="page", name="x")]
assert [p.id for p in _topological_page_order(dangling)] == ["x"]
def test_export_jsonb_coercion_preserves_decoded_scalar_string():
"""Admin connections decode JSONB before the transfer exporter sees it."""
from hindsight_api.engine.transfer.export import _as_jsonb
@@ -630,6 +677,54 @@ async def test_bank_export_import_exact_roundtrip(memory, request_context):
await memory.delete_bank(bank, request_context=request_context)
@pytest.mark.asyncio
async def test_bank_import_into_new_id_on_same_instance(memory, request_context):
"""Export a bank and import it RIGHT BACK into a new id on the same instance
(source bank left in place). The banks row carries a globally-unique
``internal_id``; if that were kept, the copy's banks INSERT would collide with
the still-present source and ``ON CONFLICT DO NOTHING`` would skip the parent
row, so the mental_models insert would trip fk_mental_models_bank_id. Import
must mint a fresh internal_id so the copy lands cleanly. Regression for #3270."""
source = _unique_bank("bank_src")
target = _unique_bank("bank_copy")
try:
await _retain(memory, source, "Alice works at Google.", request_context, "doc-1")
await memory.create_mental_model(
source,
name="Work model",
source_query="where do people work",
content="User tracks where people work.",
mental_model_id="mm-1",
request_context=request_context,
)
backend = await memory._get_backend()
async with acquire_with_retry(backend) as conn:
source_internal_id = await conn.fetchval(
f"SELECT internal_id FROM {fq_table('banks')} WHERE bank_id = $1", source
)
from hindsight_api.engine.transfer import export_bank
async with acquire_with_retry(backend) as conn:
archive = await export_bank(conn, source)
# Source bank is left in place — this is the same-instance "make a copy" flow.
result = await memory.import_bank_async(archive, request_context, target_bank_id=target)
assert result.bank_id == target
assert result.mental_models_imported == 1
async with acquire_with_retry(backend) as conn:
target_internal_id = await conn.fetchval(
f"SELECT internal_id FROM {fq_table('banks')} WHERE bank_id = $1", target
)
# The copy exists (parent row landed) and got a fresh, non-colliding id.
assert target_internal_id is not None
assert target_internal_id != source_internal_id
finally:
await memory.delete_bank(source, request_context=request_context)
await memory.delete_bank(target, request_context=request_context)
@pytest.mark.asyncio
async def test_bank_roundtrip_carries_mental_model_history(memory, request_context):
"""Mental-model refresh history survives export/import. Mental models keep a
@@ -667,6 +762,77 @@ async def test_bank_roundtrip_carries_mental_model_history(memory, request_conte
await memory.delete_bank(bank, request_context=request_context)
@pytest.mark.asyncio
async def test_bank_roundtrip_carries_knowledge_pages(memory, request_context):
"""A whole-bank archive restores the Knowledge Pages tree — nested folders +
pages, parent_id / mental_model_id / managed / sort_order preserved and
regenerates each backing mental model's embedding + lexical state on the
target, so pages stay searchable after import (#3308, #3323)."""
bank = _unique_bank("bank_kb")
try:
await memory.get_bank_profile(bank, request_context=request_context)
root = await memory.create_knowledge_folder(bank, "Runbooks", managed=True, request_context=request_context)
sub = await memory.create_knowledge_folder(
bank, "Billing", parent_id=root["id"], request_context=request_context
)
page = await memory.create_knowledge_page(
bank,
name="Net-30 policy",
source_query="what is our billing policy",
content="Invoices are due Net-30. Late payments accrue interest.",
parent_id=sub["id"],
request_context=request_context,
)
# A root-level page (NULL parent) exercises the non-nested path too.
await memory.create_knowledge_page(
bank,
name="Overview",
source_query="overview",
content="Company overview and mission statement.",
request_context=request_context,
)
def _tree(nodes):
return sorted(
(n["id"], n["kind"], n["parent_id"], n["mental_model_id"], n["managed"], n["name"]) for n in nodes
)
before = _tree(await memory.list_knowledge_nodes(bank, request_context=request_context))
before_search = await memory.search_knowledge_pages(
bank, "net-30 billing", limit=5, request_context=request_context
)
assert any(r["id"] == page["id"] for r in before_search), "page should be searchable before export"
from hindsight_api.engine.transfer import export_bank
backend = await memory._get_backend()
async with acquire_with_retry(backend) as conn:
archive = await export_bank(conn, bank)
# Delete then restore into the same id — exact round-trip, no PK collisions.
await memory.delete_bank(bank, request_context=request_context)
result = await memory.import_bank_async(archive, request_context)
assert result.knowledge_pages_imported == 4 # 2 folders + 2 pages
# Tree restored exactly: ids, parents, backing mental models, managed flag.
after = _tree(await memory.list_knowledge_nodes(bank, request_context=request_context))
assert after == before
# Backing mental models re-embedded on the target (no NULL vectors), so both
# the vector and lexical arms of knowledge search work again.
async with acquire_with_retry(backend) as conn:
null_embeddings = await conn.fetchval(
f"SELECT count(*) FROM {fq_table('mental_models')} WHERE bank_id = $1 AND embedding IS NULL",
bank,
)
assert null_embeddings == 0, "restored mental models must be re-embedded"
after_search = await memory.search_knowledge_pages(
bank, "net-30 billing", limit=5, request_context=request_context
)
assert any(r["id"] == page["id"] for r in after_search), "page must be searchable after import"
finally:
await memory.delete_bank(bank, request_context=request_context)
@pytest.mark.asyncio
async def test_import_bank_rejects_documents_archive(memory, request_context):
"""A documents-only archive must be rejected by the bank importer."""
@@ -900,8 +1066,8 @@ async def test_transfer_preserves_legacy_causal_links(memory, request_context):
async with acquire_with_retry(backend) as conn:
await conn.executemany(
f"INSERT INTO {fq_table('memory_links')} "
"(from_unit_id, to_unit_id, link_type, bank_id, weight) "
"VALUES ($1, $2, $3, $4, 1.0)",
"(from_unit_id, to_unit_id, link_type, entity_id, bank_id, weight) "
"VALUES ($1, $2, $3, NULL, $4, 1.0)",
[(from_unit_id, to_unit_id, link_type, src) for link_type in legacy_types],
)
@@ -1171,27 +1337,49 @@ async def test_import_on_conflict_modes(memory, request_context):
@pytest.mark.asyncio
async def test_http_export_import_endpoints(api_client, memory, request_context):
"""Round trip through the HTTP export (GET) and import (POST multipart) endpoints."""
"""Round trip through the async HTTP export (POST + poll + download) and import endpoints."""
src = _unique_bank("transfer_http_src")
dst = _unique_bank("transfer_http_dst")
try:
await _retain(memory, src, "Dana lives in Berlin.", request_context, document_id="doc-http")
export = await api_client.get(f"/v1/default/banks/{src}/document-transfer")
assert export.status_code == 200
assert export.headers["content-type"] == "application/zip"
archive = export.content
assert len(archive) > 0
# The old synchronous GET export is removed — it returns 410 pointing at
# the async endpoint (it could take down the shared API on large banks).
removed = await api_client.get(f"/v1/default/banks/{src}/document-transfer")
assert removed.status_code == 410
assert "document-transfer/export" in removed.json()["detail"]
# include_observations + a document_id subset is a 400.
bad = await api_client.get(
f"/v1/default/banks/{src}/document-transfer",
# Async export: POST returns 202 + operation_id, runs inline under the
# SyncTaskBackend test fixture, so it's completed by the time we poll.
submit = await api_client.post(f"/v1/default/banks/{src}/document-transfer/export")
assert submit.status_code == 202
export_op = submit.json()["operation_id"]
export_status = await api_client.get(f"/v1/default/banks/{src}/operations/{export_op}")
assert export_status.status_code == 200
export_meta = export_status.json()["result_metadata"]
assert export_meta["byte_size"] > 0
download_url = export_meta["download_url"]
assert download_url.startswith("/v1/default/files/download/banks/")
# Download the finished archive through the download route.
download = await api_client.get(download_url)
assert download.status_code == 200
assert download.headers["content-type"] == "application/zip"
archive = download.content
assert len(archive) > 0
# It is a real transfer archive for this bank.
parsed = parse_archive(archive)
assert parsed.manifest.source_bank_id == src
# include_observations + a document_id subset is a 400 (validated up front).
bad = await api_client.post(
f"/v1/default/banks/{src}/document-transfer/export",
params={"document_id": "meeting-notes", "include_observations": "true"},
)
assert bad.status_code == 400
# Import is async: returns 202 + operation_id (runs inline under the
# SyncTaskBackend test fixture, so it's completed by the time we poll).
# Import is async: returns 202 + operation_id.
imported = await api_client.post(
f"/v1/default/banks/{dst}/document-transfer",
files={"file": ("transfer.zip", archive, "application/zip")},
@@ -1208,7 +1396,7 @@ async def test_http_export_import_endpoints(api_client, memory, request_context)
assert op["result_metadata"]["facts_imported"] >= 1
# Exporting a bank that does not exist is a 404.
missing = await api_client.get("/v1/default/banks/does-not-exist-bank/document-transfer")
missing = await api_client.post("/v1/default/banks/does-not-exist-bank/document-transfer/export")
assert missing.status_code == 404
finally:
await memory.delete_bank(src, request_context=request_context)
@@ -1225,10 +1413,15 @@ async def test_endpoints_disabled_by_config(api_client, monkeypatch):
monkeypatch.setenv("HINDSIGHT_API_ENABLE_DOCUMENT_IMPORT_API", "false")
clear_config_cache()
try:
export = await api_client.get("/v1/default/banks/any-bank/document-transfer")
export = await api_client.post("/v1/default/banks/any-bank/document-transfer/export")
assert export.status_code == 404
assert "disabled" in export.json()["detail"].lower()
# The download route serves export archives, so it is gated on the same flag.
download = await api_client.get("/v1/default/files/download/banks/any-bank/exports/x/transfer.zip")
assert download.status_code == 404
assert "disabled" in download.json()["detail"].lower()
imported = await api_client.post(
"/v1/default/banks/any-bank/document-transfer",
files={"file": ("x.zip", b"not-a-zip", "application/zip")},
@@ -1257,6 +1450,51 @@ async def test_import_rejects_unsupported_schema_version(memory, request_context
await memory.import_documents_async("any-bank", buffer.getvalue(), request_context)
def test_parse_archive_rejects_a_file_that_is_not_a_zip():
"""Garbage bytes are a caller error (400), not a zipfile.BadZipFile crash (500)."""
with pytest.raises(ValueError, match="not a readable .zip"):
parse_archive(b"%PDF-1.7 this is not a zip at all")
def test_parse_archive_rejects_a_plain_zip_of_files():
"""A zip of ordinary documents is refused with a message that names the fix.
Regression for #3327: users read "Import from zip" as a bulk upload of their
own PDFs/text files, so the rejection has to say where that actually lives
instead of only naming the missing manifest.
"""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
zf.writestr("notes.txt", "Dana lives in Berlin.")
zf.writestr("report.pdf", "%PDF-1.7")
with pytest.raises(ValueError, match="manifest.json is missing") as excinfo:
parse_archive(buffer.getvalue())
assert "retain" in str(excinfo.value)
@pytest.mark.asyncio
async def test_http_import_rejects_non_transfer_zip_with_400(api_client):
"""The wrong zip fails fast with a 400 whose detail explains what to upload."""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
zf.writestr("notes.txt", "Dana lives in Berlin.")
response = await api_client.post(
"/v1/default/banks/any-bank/document-transfer",
files={"file": ("my-documents.zip", buffer.getvalue(), "application/zip")},
)
assert response.status_code == 400
assert "manifest.json is missing" in response.json()["detail"]
not_a_zip = await api_client.post(
"/v1/default/banks/any-bank/document-transfer",
files={"file": ("notes.pdf", b"%PDF-1.7", "application/pdf")},
)
assert not_a_zip.status_code == 400
assert "not a readable .zip" in not_a_zip.json()["detail"]
@pytest.mark.asyncio
async def test_import_rejects_invalid_on_conflict(memory, request_context):
"""An unknown on_conflict mode is rejected with a ValueError."""
@@ -1353,3 +1591,179 @@ async def test_bank_import_classifies_label_entities(memory, request_context):
assert kinds.get(regular_entity) == "regular", kinds
finally:
await memory.delete_bank(bank, request_context=request_context)
@pytest.mark.asyncio
async def test_async_export_roundtrip(memory, request_context):
"""The async export operation stashes a real archive that re-imports cleanly.
Mirrors the synchronous round trip, but through submit_export_documents_async:
the worker (inline under SyncTaskBackend) builds the ZIP, stores it, and
records the storage key / download URL / size in the operation's
result_metadata.
"""
src = _unique_bank("async_export_src")
dst = _unique_bank("async_export_dst")
try:
await _retain(memory, src, "Alice works at Google. Bob works at Microsoft.", request_context, "doc-1")
meta, archive = await _export_async(memory, src, request_context)
assert meta["storage_key"].startswith(f"banks/{src}/exports/")
assert meta["download_url"] == f"/v1/default/files/download/{meta['storage_key']}"
assert meta["byte_size"] == len(archive)
assert meta["filename"] == f"{src}-documents.zip"
parsed = parse_archive(archive)
assert parsed.manifest.source_bank_id == src
exported_texts = {fact.text for doc in parsed.documents for fact in doc.facts}
assert exported_texts
result = await _import(memory, dst, archive, request_context)
assert result["facts_imported"] == parsed.manifest.fact_count
units = await memory.list_memory_units(dst, request_context=request_context)
imported = {u["text"] for u in units["items"] if u["fact_type"] != "observation"}
assert imported == exported_texts
finally:
await memory.delete_bank(src, request_context=request_context)
await memory.delete_bank(dst, request_context=request_context)
@pytest.mark.asyncio
async def test_async_export_include_observations_subset_rejected(memory, request_context):
"""include_observations with a document subset fails fast (before enqueue)."""
with pytest.raises(ValueError, match="whole bank"):
await memory.submit_export_documents_async(
"any-bank", request_context, document_ids=["doc-1"], include_observations=True
)
@pytest.mark.asyncio
async def test_export_attach_batching_preserves_entities_and_causal_links(memory, request_context, monkeypatch):
"""Batched attach queries carry every fact's entities and cross-batch causal edges.
With _ATTACH_BATCH_SIZE forced to 1 each unit lands in its own batch, so a
causal edge whose endpoints fall in different batches is exactly the case the
old ``to_unit_id = ANY(<full set>)`` filter covered the Python-side target
check must still attach it.
"""
from hindsight_api.engine.transfer import export as export_mod
bank = _unique_bank("attach_batch")
try:
await _retain(memory, bank, "Alice works at Google. Bob works at Microsoft.", request_context, "doc-1")
# Insert a synthetic caused_by edge between two facts of the same document,
# ordered the same way export assigns fact ordinals (created_at, id).
backend = await memory._get_backend()
async with acquire_with_retry(backend) as conn:
rows = await conn.fetch(
f"SELECT id FROM {fq_table('memory_units')} WHERE bank_id = $1 AND document_id = 'doc-1' "
"AND fact_type IN ('world', 'experience') ORDER BY created_at, id",
bank,
)
assert len(rows) >= 2, "need at least two facts to link"
source_id, target_id = rows[0]["id"], rows[1]["id"]
await conn.execute(
f"INSERT INTO {fq_table('memory_links')} (bank_id, from_unit_id, to_unit_id, link_type) "
"VALUES ($1, $2, $3, 'caused_by') ON CONFLICT DO NOTHING",
bank,
source_id,
target_id,
)
monkeypatch.setattr(export_mod, "_ATTACH_BATCH_SIZE", 1)
_, archive = await _export_async(memory, bank, request_context)
parsed = parse_archive(archive)
doc = next(d for d in parsed.documents if d.id == "doc-1")
# Every fact kept its entities despite one-unit-per-batch fetching.
all_entities = {name for fact in doc.facts for name in fact.entities}
assert any("alice" in n.lower() for n in all_entities), all_entities
assert any("bob" in n.lower() for n in all_entities), all_entities
# The cross-batch causal edge survived: fact 0 points at fact 1.
relations = doc.facts[0].causal_relations
assert any(r.relation_type == "caused_by" and r.target_fact_index == 1 for r in relations), relations
finally:
await memory.delete_bank(bank, request_context=request_context)
@pytest.mark.asyncio
async def test_delete_operation_removes_export_archive(memory, request_context):
"""Deleting an export operation also deletes its stored archive (no orphan blob)."""
bank = _unique_bank("export_delete")
try:
await _retain(memory, bank, "Alice works at Google.", request_context, "doc-1")
submission = await memory.submit_export_documents_async(bank, request_context)
op_id = submission["operation_id"]
status = await memory.get_operation_status(bank, op_id, request_context=request_context)
storage_key = status["result_metadata"]["storage_key"]
# The archive exists while the operation does.
assert await memory._file_storage.retrieve(storage_key)
# Deleting the operation deletes the archive with it.
await memory.delete_operation(bank, op_id, request_context=request_context)
with pytest.raises(FileNotFoundError):
await memory._file_storage.retrieve(storage_key)
finally:
await memory.delete_bank(bank, request_context=request_context)
@pytest.mark.asyncio
async def test_purge_expired_export_archives(memory, request_context):
"""Retention's archive purge deletes the blobs of export ops past the cutoff."""
from datetime import timedelta
bank = _unique_bank("export_purge")
try:
await _retain(memory, bank, "Bob works at Microsoft.", request_context, "doc-1")
submission = await memory.submit_export_documents_async(bank, request_context)
op_id = submission["operation_id"]
status = await memory.get_operation_status(bank, op_id, request_context=request_context)
storage_key = status["result_metadata"]["storage_key"]
assert await memory._file_storage.retrieve(storage_key)
# The purge is schema-wide (it doesn't take a bank), and this DB is shared
# across xdist workers — so backdate THIS op and use a past cutoff to target
# it specifically. A future cutoff would purge other concurrent tests' fresh
# export archives too (they'd be < cutoff), making both this count and those
# tests flaky.
backend = await memory._get_backend()
old = datetime.now(timezone.utc) - timedelta(days=100)
cutoff = datetime.now(timezone.utc) - timedelta(days=1)
async with acquire_with_retry(backend) as conn:
await conn.execute(
f"UPDATE {fq_table('async_operations')} SET updated_at = $1 WHERE operation_id = $2",
old,
uuid.UUID(op_id),
)
purged = await memory.purge_expired_export_archives(conn, fq_table("async_operations"), cutoff)
assert purged >= 1
with pytest.raises(FileNotFoundError):
await memory._file_storage.retrieve(storage_key)
finally:
await memory.delete_bank(bank, request_context=request_context)
@pytest.mark.asyncio
async def test_download_route_rejects_unauthorized_keys(api_client, memory, request_context):
"""The download route only serves bank-scoped keys for banks the caller can see."""
bank = _unique_bank("download_guard")
try:
await memory.get_bank_profile(bank_id=bank, request_context=request_context)
# Non-"banks/"-prefixed key: not a downloadable resource.
r = await api_client.get("/v1/default/files/download/etc/passwd")
assert r.status_code == 404
# Path-traversal attempt is rejected structurally.
r = await api_client.get("/v1/default/files/download/banks/../secrets/x.zip")
assert r.status_code == 404
# Well-formed key for a bank that does not exist (IDOR guard via bank read).
r = await api_client.get("/v1/default/files/download/banks/no-such-bank/exports/x/transfer.zip")
assert r.status_code == 404
# Well-formed key for a visible bank but no such stored file: 404, not 500.
r = await api_client.get(f"/v1/default/files/download/banks/{bank}/exports/missing/transfer.zip")
assert r.status_code == 404
finally:
await memory.delete_bank(bank, request_context=request_context)
@@ -0,0 +1,175 @@
"""Candidate entity-name hygiene at resolution intake (issue #3275).
``_prepare_entities_for_resolution`` is the single choke point both entity
resolution entry paths funnel through (retain via
``entity_processing.resolve_entities``, and the memory-edit path in
``MemoryEngine``), so it is where a name is made safe to store:
1. whitespace runs including the ``\\n`` extraction sometimes leaves behind
collapse to a single space, and the ends are stripped;
2. a name that is empty afterwards is dropped rather than stored as an entity
with a blank ``canonical_name``;
3. candidates that normalization made identical are deduplicated per fact.
All of it runs before the flat list / ``entity_to_unit`` mapping is derived, so
the resolver's positional invariant is untouched.
"""
import pytest
from hindsight_api.engine.retain.link_utils import (
_normalize_entity_name,
_prepare_entities_for_resolution,
)
class _FakeEntity:
"""Object-style candidate: exposes ``.text``, like the extraction models."""
def __init__(self, text: str):
self.text = text
def _texts(all_entities_flat: list[dict]) -> list[str]:
return [e["text"] for e in all_entities_flat]
def _prepare(entities: list, unit_ids: list[str] | None = None):
"""Run one fact's candidate list through intake."""
return _prepare_entities_for_resolution(
unit_ids=unit_ids or ["u1"],
sentences=["fact text"],
fact_dates=[None],
llm_entities=[entities],
)
# --- _normalize_entity_name ---
@pytest.mark.parametrize(
"raw,expected",
[
("Acme\nCorp", "Acme Corp"),
("a\r\n b\tc", "a b c"),
(" leading and trailing ", "leading and trailing"),
("multiple spaces inside", "multiple spaces inside"),
("Normal Name", "Normal Name"),
("", ""),
(" \n\t ", ""),
],
)
def test_normalize_entity_name(raw, expected):
assert _normalize_entity_name(raw) == expected
def test_normalize_entity_name_preserves_case():
# The registry matches on LOWER(canonical_name); lowercasing here would only
# destroy the display form.
assert _normalize_entity_name("MiXeD\nCaSe") == "MiXeD CaSe"
def test_normalize_entity_name_leaves_ordinary_punctuation_alone():
assert _normalize_entity_name("Dr. Foo-Bar (ACME), Inc.") == "Dr. Foo-Bar (ACME), Inc."
# --- intake: normalization reaches the text handed to the resolver ---
def test_intake_normalizes_dict_style_candidates():
all_entities_flat, _all, _map = _prepare([{"text": "Acme\nCorp", "type": "ORG"}])
assert _texts(all_entities_flat) == ["Acme Corp"]
assert all_entities_flat[0]["type"] == "ORG"
def test_intake_normalizes_object_style_candidates():
all_entities_flat, _all, _map = _prepare([_FakeEntity("a\r\n b\tc")])
assert _texts(all_entities_flat) == ["a b c"]
def test_intake_normalizes_nearby_entities_too():
# nearby_entities is the co-occurrence signal the resolver scores against;
# it must carry the normalized names, not the raw ones.
all_entities_flat, all_entities, _map = _prepare(
[{"text": "Acme\nCorp", "type": "CONCEPT"}, {"text": "Alice", "type": "CONCEPT"}]
)
assert [e["text"] for e in all_entities[0]] == ["Acme Corp", "Alice"]
assert [e["text"] for e in all_entities_flat[1]["nearby_entities"]] == ["Acme Corp", "Alice"]
# --- intake: empty names are dropped, not stored blank ---
@pytest.mark.parametrize("raw", ["", " ", "\n", " \t\r\n "])
def test_intake_drops_empty_and_whitespace_only_candidates(raw):
all_entities_flat, all_entities, entity_to_unit = _prepare([{"text": raw, "type": "CONCEPT"}])
assert all_entities_flat == []
assert all_entities == [[]]
assert entity_to_unit == []
def test_intake_drops_candidate_dict_without_text_key():
all_entities_flat, _all, _map = _prepare([{"type": "CONCEPT"}, {"text": "Alice", "type": "CONCEPT"}])
assert _texts(all_entities_flat) == ["Alice"]
def test_intake_keeps_real_entities_alongside_dropped_empties():
all_entities_flat, _all, entity_to_unit = _prepare(
[{"text": " ", "type": "CONCEPT"}, {"text": " Alice ", "type": "CONCEPT"}]
)
assert _texts(all_entities_flat) == ["Alice"]
# entity_to_unit stays index-aligned with the flat list the resolver receives.
assert entity_to_unit == [("u1", 0, None)]
# --- intake: dedup of candidates that normalization made identical ---
def test_intake_dedupes_candidates_normalization_made_identical():
# The upstream dedup in entity_processing runs on raw text, so these two
# arrive here distinct and collide only after normalization.
all_entities_flat, all_entities, entity_to_unit = _prepare(
[{"text": "Acme\nCorp", "type": "CONCEPT"}, {"text": "Acme Corp", "type": "CONCEPT"}]
)
assert _texts(all_entities_flat) == ["Acme Corp"]
assert [e["text"] for e in all_entities[0]] == ["Acme Corp"]
assert len(entity_to_unit) == 1
def test_intake_dedupe_is_case_insensitive_and_keeps_first_spelling():
all_entities_flat, _all, _map = _prepare(
[{"text": "Acme Corp", "type": "CONCEPT"}, {"text": "acme\ncorp", "type": "CONCEPT"}]
)
assert _texts(all_entities_flat) == ["Acme Corp"]
def test_intake_dedupe_is_scoped_per_fact():
# The same entity mentioned by two different facts must still be resolved
# for each of them — the dedup is within a fact, not across the batch.
all_entities_flat, _all, entity_to_unit = _prepare_entities_for_resolution(
unit_ids=["u1", "u2"],
sentences=["first", "second"],
fact_dates=[None, None],
llm_entities=[
[{"text": "Acme\nCorp", "type": "CONCEPT"}],
[{"text": "Acme Corp", "type": "CONCEPT"}],
],
)
assert _texts(all_entities_flat) == ["Acme Corp", "Acme Corp"]
assert [unit_id for unit_id, _idx, _date in entity_to_unit] == ["u1", "u2"]
def test_intake_attaches_fact_dates_after_dropping():
from datetime import UTC, datetime
when = datetime(2026, 8, 8, tzinfo=UTC)
all_entities_flat, _all, _map = _prepare_entities_for_resolution(
unit_ids=["u1"],
sentences=["fact text"],
fact_dates=[when],
llm_entities=[[{"text": " ", "type": "CONCEPT"}, {"text": "Alice", "type": "CONCEPT"}]],
)
# The event_date attach loop walks entity_to_unit positionally; a dropped
# candidate must not shift it.
assert _texts(all_entities_flat) == ["Alice"]
assert all_entities_flat[0]["event_date"] == when
@@ -0,0 +1,156 @@
"""Gemini deterministic-400 handling (#3256).
An HTTP 400 ``INVALID_ARGUMENT`` is a deterministic client-side rejection: the
schema/prompt/generation-config the bank compiled is malformed, so every retry
repeats an identical rejected call. These tests pin the two fixes:
1. Retry classification a 400 fails fast; it must NOT consume the LLM retry
budget (nor, above it, the batch retry ladder). A retryable 503 still burns
the full budget, so the distinction is real.
2. Diagnosability a 400 always emits the content-free structural profile
(``[LLM_4XX_DUMP]``) even with the opt-in flag off, so the otherwise-opaque
failure is diagnosable on first occurrence. A recoverable cache-400 must not
be mistaken for a deterministic rejection.
"""
import logging
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
pytest.importorskip("google.genai")
from google.genai import errors as genai_errors # noqa: E402
def _make_gemini_provider():
"""Return a GeminiLLM instance with a mocked genai.Client."""
with patch("google.genai.Client") as mock_client_cls:
mock_client_cls.return_value = MagicMock()
from hindsight_api.engine.providers.gemini_llm import GeminiLLM
provider = GeminiLLM(
provider="gemini",
api_key="fake-api-key",
base_url="",
model="gemini-2.5-flash",
)
provider._client = MagicMock()
return provider
def _api_error(code: int, status: str = "INVALID_ARGUMENT") -> genai_errors.APIError:
return genai_errors.APIError(code, {"error": {"message": f"{status}: rejected", "status": status}})
@pytest.mark.asyncio
async def test_call_400_fails_fast_without_consuming_retries():
"""A 400 raises after a single attempt — no retry budget is burned."""
provider = _make_gemini_provider()
generate = AsyncMock(side_effect=_api_error(400))
provider._client.aio.models.generate_content = generate
with pytest.raises(genai_errors.APIError) as excinfo:
await provider.call(
messages=[{"role": "user", "content": "hi"}],
scope="consolidation",
max_retries=4,
initial_backoff=0.0,
)
assert excinfo.value.code == 400
assert generate.call_count == 1 # NOT 5 (1 + 4 retries)
@pytest.mark.asyncio
async def test_call_503_still_consumes_full_retry_budget():
"""A retryable error still burns the budget — the fail-fast is 400-specific."""
provider = _make_gemini_provider()
generate = AsyncMock(side_effect=_api_error(503, status="UNAVAILABLE"))
provider._client.aio.models.generate_content = generate
with pytest.raises(genai_errors.APIError):
await provider.call(
messages=[{"role": "user", "content": "hi"}],
scope="consolidation",
max_retries=3,
initial_backoff=0.0,
)
assert generate.call_count == 4 # 1 + 3 retries
@pytest.mark.asyncio
async def test_call_with_tools_400_fails_fast():
"""The tool path fails fast on 400 too."""
provider = _make_gemini_provider()
generate = AsyncMock(side_effect=_api_error(400))
provider._client.aio.models.generate_content = generate
with pytest.raises(genai_errors.APIError) as excinfo:
await provider.call_with_tools(
messages=[{"role": "user", "content": "hi"}],
tools=[
{
"type": "function",
"function": {"name": "noop", "description": "n", "parameters": {"type": "object"}},
}
],
scope="consolidation",
max_retries=4,
initial_backoff=0.0,
)
assert excinfo.value.code == 400
assert generate.call_count == 1
@pytest.mark.asyncio
async def test_call_400_always_dumps_structural_profile(monkeypatch, caplog):
"""The structural profile is logged on a 400 even with the opt-in flag off,
and carries no user content (only per-part sizes)."""
from hindsight_api.config import ENV_LLM_DEBUG_DUMP_4XX, clear_config_cache
monkeypatch.delenv(ENV_LLM_DEBUG_DUMP_4XX, raising=False)
clear_config_cache()
provider = _make_gemini_provider()
provider._client.aio.models.generate_content = AsyncMock(side_effect=_api_error(400))
with caplog.at_level(logging.ERROR):
with pytest.raises(genai_errors.APIError):
await provider.call(
messages=[{"role": "user", "content": "sensitive memory text"}],
scope="consolidation",
max_retries=4,
initial_backoff=0.0,
)
assert "[LLM_4XX_DUMP]" in caplog.text
assert "code=400" in caplog.text
assert "sensitive memory text" not in caplog.text # forced dump omits previews
clear_config_cache()
@pytest.mark.asyncio
async def test_call_503_does_not_force_dump(monkeypatch, caplog):
"""A retryable non-4xx never triggers the forced structural dump."""
from hindsight_api.config import ENV_LLM_DEBUG_DUMP_4XX, clear_config_cache
monkeypatch.delenv(ENV_LLM_DEBUG_DUMP_4XX, raising=False)
clear_config_cache()
provider = _make_gemini_provider()
provider._client.aio.models.generate_content = AsyncMock(side_effect=_api_error(503, status="UNAVAILABLE"))
with caplog.at_level(logging.ERROR):
with pytest.raises(genai_errors.APIError):
await provider.call(
messages=[{"role": "user", "content": "hi"}],
scope="consolidation",
max_retries=1,
initial_backoff=0.0,
)
assert "[LLM_4XX_DUMP]" not in caplog.text
clear_config_cache()
@@ -0,0 +1,65 @@
"""Regression tests for UUID validation on get_entity and get_observation_history.
Mirrors the pattern in test_delete_memory_units_validation.py: a stub engine
that bypasses __init__ so the pure-Python validation branches are exercised
without a DB connection.
get_memory_unit and update_memory_unit already validate their memory_id
against uuid.UUID (PR #3062, commit in #906). get_entity and
get_observation_history were missed - a malformed id raised a bare
ValueError from the stdlib that the HTTP handler mapped to 500 instead
of 400.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from hindsight_api import RequestContext
from hindsight_api.engine.memory_engine import MemoryEngine
def _stub_engine() -> MemoryEngine:
engine = object.__new__(MemoryEngine)
engine._authenticate_tenant = AsyncMock()
engine._operation_validator = None
# 让 _get_backend 返回一个 mock conn,但它永远不会被到达
# 因为 uuid.UUID(bad) 会先 raise
engine._get_backend = AsyncMock(return_value=MagicMock())
return engine
BAD_UUID = "not-a-uuid"
@pytest.mark.asyncio
async def test_get_entity_rejects_malformed_uuid():
"""get_entity 应对畸形 UUID raise ValueError(而非让 uuid.UUID 裸抛)。"""
engine = _stub_engine()
with pytest.raises(ValueError, match="Invalid entity_id"):
await engine.get_entity(
bank_id="test-bank",
entity_id=BAD_UUID,
request_context=RequestContext(api_key="anything"),
)
# 不应该到达 DB 层
engine._get_backend.assert_not_awaited()
@pytest.mark.asyncio
async def test_get_observation_history_rejects_malformed_uuid():
"""get_observation_history 应对畸形 UUID raise ValueError。"""
engine = _stub_engine()
with pytest.raises(ValueError, match="Invalid memory_id"):
await engine.get_observation_history(
bank_id="test-bank",
memory_id=BAD_UUID,
request_context=RequestContext(api_key="anything"),
)
engine._get_backend.assert_not_awaited()
@@ -191,10 +191,24 @@ class TestEnqueueRelinkVictims:
assert count == 0
assert await _queue_unit_ids(conn, bank_id) == []
# Entity links used to be skipped here by the ``link_type IN ('temporal',
# 'semantic')`` filter; they can no longer be stored in memory_links at all
# (the CHECK constraint rejects link_type = 'entity'), so there is nothing
# left to construct a "skips entity links" scenario from.
@pytest.mark.asyncio
async def test_skips_entity_links(self, memory: MemoryEngine, request_context: RequestContext):
"""Entity links are being removed from the product — we don't enqueue for them."""
bank_id = f"test-gm-ent-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
doomed = await _insert_unit(conn, bank_id, "doomed")
survivor = await _insert_unit(conn, bank_id, "survivor")
# Only an entity link — should NOT trigger enqueue.
await _insert_link(conn, bank_id, survivor, doomed, "entity")
backend = await memory._get_backend()
async with conn.transaction():
count = await enqueue_relink_victims(conn, bank_id, [str(doomed)])
assert count == 0
@pytest.mark.asyncio
async def test_dedupes_via_on_conflict(self, memory: MemoryEngine, request_context: RequestContext):
@@ -0,0 +1,308 @@
"""Per-bank serialisation of graph_maintenance at claim time (#3230).
Every graph_maintenance run is the same bank-wide sweep the payload carries
only ``bank_id`` and ``run_graph_maintenance_job`` drains the whole queue so a
second concurrent run for one bank adds no work while convoying on the first
run's queue-row locks (``claim_graph_maintenance_batch`` locks ``FOR UPDATE``
with no ``SKIP LOCKED``) and holding a worker slot.
``claim_tasks`` therefore refuses to claim a graph_maintenance row for a bank
that already has one in flight, and takes at most one per bank per batch. It
does that with a predicate on the ordinary shared-pool query rather than a
claim phase of its own, so graph_maintenance keeps competing by ``created_at``
instead of dropping below every other operation type it has no reserved-slot
floor, and the poller's fairness pass claims with ``shared_limit=1``.
These call ``ops.claim_tasks`` directly rather than ``WorkerPoller.claim_batch``
so the slot limits under test are exact and not a function of ambient in-flight
work in the shared test database.
"""
import json
import uuid
from datetime import UTC, datetime, timedelta
import pytest
import pytest_asyncio
# Use loadgroup to ensure these tests run in the same worker
# since they share database state
pytestmark = pytest.mark.xdist_group("worker_tests")
_TABLE = "async_operations"
@pytest_asyncio.fixture
async def backend(pg0_db_url):
"""Create a DatabaseBackend for claim tests."""
from hindsight_api.engine.db import create_database_backend
from hindsight_api.pg0 import resolve_database_url
resolved_url = await resolve_database_url(pg0_db_url)
b = create_database_backend("postgresql")
await b.initialize(resolved_url, min_size=2, max_size=10, command_timeout=30)
yield b
await b.shutdown()
@pytest_asyncio.fixture
async def pool(backend):
"""Expose the raw asyncpg pool from the backend for direct DB access in tests."""
yield backend.get_pool()
@pytest_asyncio.fixture
async def clean_operations(pool):
"""Isolate these tests from other pending work in the shared schema.
Same rationale as test_worker.py's fixture: the claim queries scan the whole
schema, so stale pending rows from other tests would be claimed here and
consume the (deliberately small) slot limits under test.
"""
await pool.execute("DELETE FROM async_operations WHERE status = 'pending'")
yield
await pool.execute("DELETE FROM async_operations WHERE bank_id LIKE 'test-gmclaim-%'")
async def _make_bank(pool) -> str:
"""Create a bank with a unique id so concurrent runs can't collide."""
bank_id = f"test-gmclaim-{uuid.uuid4().hex[:8]}"
await pool.execute(
"INSERT INTO banks (bank_id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING",
bank_id,
bank_id,
)
return bank_id
async def _insert_op(
pool,
bank_id: str,
op_type: str,
status: str = "pending",
*,
created_at: datetime | None = None,
claimed_at: datetime | None = None,
next_retry_at: datetime | None = None,
worker_id: str | None = None,
) -> uuid.UUID:
"""Insert one operation row with a claimable payload."""
op_id = uuid.uuid4()
payload = json.dumps({"type": op_type, "bank_id": bank_id, "operation_id": str(op_id)})
await pool.execute(
f"""
INSERT INTO {_TABLE}
(operation_id, bank_id, operation_type, status, task_payload, worker_id, next_retry_at,
created_at, claimed_at, updated_at)
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7,
COALESCE($8, now()), $9, now())
""",
op_id,
bank_id,
op_type,
status,
payload,
worker_id,
next_retry_at,
created_at,
claimed_at,
)
return op_id
async def _claim(backend, *, shared: int = 10, reserved: dict[str, int] | None = None) -> set[str]:
"""Run one claim cycle and return the claimed operation ids as strings."""
async with backend.acquire() as conn:
async with conn.transaction():
rows = await backend.ops.claim_tasks(
conn,
_TABLE,
"test-gmclaim-worker",
reserved or {},
shared,
)
return {str(row["operation_id"]) for row in rows}
async def _status_of(pool, op_id: uuid.UUID) -> str:
return await pool.fetchval(f"SELECT status FROM {_TABLE} WHERE operation_id = $1", op_id)
@pytest.mark.asyncio
async def test_not_claimed_while_bank_has_run_in_flight(pool, backend, clean_operations):
"""A pending graph_maintenance is left alone while its bank has one processing."""
bank = await _make_bank(pool)
await _insert_op(pool, bank, "graph_maintenance", "processing", claimed_at=datetime.now(UTC), worker_id="other")
pending = await _insert_op(pool, bank, "graph_maintenance")
claimed = await _claim(backend)
assert str(pending) not in claimed
assert await _status_of(pool, pending) == "pending"
@pytest.mark.asyncio
async def test_single_batch_claims_at_most_one_per_bank(pool, backend, clean_operations):
"""One batch takes a single graph_maintenance per bank — the oldest.
Multiple pending rows for one bank are reachable despite submit-time dedup:
``recover_own_tasks`` resets every processing row for a worker back to
pending in one statement, and _schedule_retry / _defer_operation / the admin
recover command each restore rows independently.
"""
bank = await _make_bank(pool)
base = datetime.now(UTC) - timedelta(minutes=10)
op_ids = [
await _insert_op(pool, bank, "graph_maintenance", created_at=base + timedelta(seconds=i)) for i in range(5)
]
claimed = await _claim(backend)
ours = [op for op in op_ids if str(op) in claimed]
assert len(ours) == 1, f"expected exactly one same-bank claim, got {len(ours)}"
assert ours[0] == op_ids[0], "the oldest pending row should be the one claimed"
@pytest.mark.asyncio
async def test_idle_bank_still_claimed_while_another_is_busy(pool, backend, clean_operations):
"""The guard is per bank, not global: a different bank is unaffected."""
busy_bank = await _make_bank(pool)
idle_bank = await _make_bank(pool)
await _insert_op(pool, busy_bank, "graph_maintenance", "processing", claimed_at=datetime.now(UTC))
blocked = await _insert_op(pool, busy_bank, "graph_maintenance")
claimable = await _insert_op(pool, idle_bank, "graph_maintenance")
claimed = await _claim(backend)
assert str(claimable) in claimed
assert str(blocked) not in claimed
@pytest.mark.asyncio
async def test_other_operation_types_unaffected(pool, backend, clean_operations):
"""A busy bank's non-graph_maintenance work is still claimed."""
bank = await _make_bank(pool)
await _insert_op(pool, bank, "graph_maintenance", "processing", claimed_at=datetime.now(UTC))
retain = await _insert_op(pool, bank, "retain")
claimed = await _claim(backend)
assert str(retain) in claimed
@pytest.mark.asyncio
async def test_not_starved_by_newer_pending_work(pool, backend, clean_operations):
"""graph_maintenance still wins the shared slot when it is the oldest row.
The poller's fairness pass claims with ``shared_limit=1`` and
graph_maintenance has no reserved-slot floor, so claiming it in a phase
*after* the generic shared-pool query would let any single pending retain
starve it indefinitely. As a predicate on that same query it keeps its place
in the created_at ordering.
"""
bank = await _make_bank(pool)
older = await _insert_op(pool, bank, "graph_maintenance", created_at=datetime.now(UTC) - timedelta(minutes=5))
await _insert_op(pool, bank, "retain")
claimed = await _claim(backend, shared=1)
assert claimed == {str(older)}
@pytest.mark.asyncio
async def test_retry_blocked_older_row_does_not_block_a_claimable_one(pool, backend, clean_operations):
"""An older row still in retry backoff must not hold up its bank.
It cannot be claimed itself, so counting it as "goes first" would stall the
bank's graph maintenance for the whole backoff window.
"""
bank = await _make_bank(pool)
await _insert_op(
pool,
bank,
"graph_maintenance",
created_at=datetime.now(UTC) - timedelta(minutes=5),
next_retry_at=datetime.now(UTC) + timedelta(hours=1),
)
claimable = await _insert_op(pool, bank, "graph_maintenance")
claimed = await _claim(backend)
assert str(claimable) in claimed
def test_guard_survives_the_oracle_sql_rewrite():
"""The shared predicate must still be valid Oracle after db/oracle.py rewrites it.
Oracle tests need an ORACLE_TEST_DSN this suite does not have, so the dialect
parity of this guard is otherwise unverified. The rewriter is pure text
substitution, so assert on its output directly: PG-only spellings must be
gone, and the ROWNUM row limit must land on the *outer* WHERE rather than the
subquery's (the rewriter replaces only the first WHERE it sees).
"""
from hindsight_api.engine.db.ops import graph_maintenance_bank_serialization_sql
from hindsight_api.engine.db.oracle import _rewrite_pg_to_oracle
table = "async_operations"
rewritten = _rewrite_pg_to_oracle(
f"""
SELECT o.operation_id FROM {table} o
WHERE o.status = 'pending'
AND (o.next_retry_at IS NULL OR o.next_retry_at <= NOW())
AND o.operation_id != ALL($1::uuid[])
AND {graph_maintenance_bank_serialization_sql(table, "o")}
ORDER BY o.created_at
LIMIT $2
FOR UPDATE SKIP LOCKED
"""
).query
assert "NOW()" not in rewritten and "SYSTIMESTAMP" in rewritten
assert "!= ALL" not in rewritten and "::uuid[]" not in rewritten
assert "LIMIT" not in rewritten
assert "FOR UPDATE SKIP LOCKED" in rewritten
assert "WHERE ROWNUM <= :2 AND o.status = 'pending'" in rewritten
# The correlated subquery keeps its own unmodified WHERE.
assert "WHERE gm_peer.bank_id = o.bank_id" in rewritten
@pytest.mark.asyncio
async def test_reserved_pool_is_serialised_too(pool, backend, clean_operations):
"""The guard also applies when graph_maintenance has reserved slots.
WORKER_SLOT_TYPE_DEFAULTS gives it 0 by default, but an operator can raise
HINDSIGHT_API_WORKER_GRAPH_MAINTENANCE_RESERVED_SLOTS, which routes claims
through the reserved-pool query instead of the shared one.
"""
bank = await _make_bank(pool)
base = datetime.now(UTC) - timedelta(minutes=10)
op_ids = [
await _insert_op(pool, bank, "graph_maintenance", created_at=base + timedelta(seconds=i)) for i in range(3)
]
claimed = await _claim(backend, shared=0, reserved={"graph_maintenance": 5})
ours = [op for op in op_ids if str(op) in claimed]
assert len(ours) == 1, f"expected exactly one same-bank claim, got {len(ours)}"
@pytest.mark.asyncio
async def test_reserved_and_shared_phases_do_not_double_claim(pool, backend, clean_operations):
"""A reserved-pool claim blocks the shared pool from taking a second one.
The two phases run in one transaction, so the row claimed in phase 1 is still
'pending' when the shared query runs and is excluded from it by operation_id.
It has to keep blocking through the guard's older-pending branch instead,
otherwise one cycle hands the same bank two concurrent runs.
"""
bank = await _make_bank(pool)
base = datetime.now(UTC) - timedelta(minutes=10)
op_ids = [
await _insert_op(pool, bank, "graph_maintenance", created_at=base + timedelta(seconds=i)) for i in range(3)
]
claimed = await _claim(backend, shared=5, reserved={"graph_maintenance": 1})
ours = [op for op in op_ids if str(op) in claimed]
assert len(ours) == 1, f"expected exactly one same-bank claim across both phases, got {len(ours)}"
@@ -0,0 +1,179 @@
"""Liveness must never depend on the database (#3329).
A liveness probe wired to a DB check restarts every pod at once when the
database is merely slow: in-flight requests die, claimed async operations are
requeued with ``retry_count`` incremented, and each restarted process reconnects
to re-warm its pool against a database that is already saturated. These tests
pin the split ``/health/live`` answers without touching the database, while
``/health`` and ``/health/ready`` keep failing closed so readiness gates traffic.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi.testclient import TestClient
_DB_DOWN = {"status": "unhealthy", "database": "error", "error": "connection refused"}
_DB_UP = {"status": "healthy", "database": "connected", "db_acquire_ms": 0.4}
def _api_client(health: dict) -> tuple[TestClient, MagicMock]:
"""API app whose engine reports ``health``, with no database behind it."""
from hindsight_api.api.http import create_app
memory = MagicMock()
# Copy: the worker handler enriches the payload it gets back in place.
memory.health_check = AsyncMock(return_value=dict(health))
return TestClient(create_app(memory, initialize_memory=False)), memory
def _worker_client(health: dict, poller: MagicMock | None = None) -> tuple[TestClient, MagicMock]:
"""Worker metrics app whose engine reports ``health``."""
from hindsight_api.worker.main import create_worker_app
memory = MagicMock()
# Copy: the worker handler enriches the payload it gets back in place.
memory.health_check = AsyncMock(return_value=dict(health))
if poller is None:
poller = MagicMock()
poller.worker_id = "w-test"
poller.is_shutdown = False
poller.seconds_since_last_poll = 0.4
return TestClient(create_worker_app(poller, memory)), memory
# ---------------------------------------------------------------------------
# Liveness: DB-free
# ---------------------------------------------------------------------------
def test_api_liveness_stays_200_when_database_is_down():
client, memory = _api_client(_DB_DOWN)
response = client.get("/health/live")
assert response.status_code == 200
body = response.json()
assert body["status"] == "alive"
assert body["version"]
assert body["uptime_seconds"] >= 0
# The point of the endpoint: it never asks the engine about the database.
assert memory.health_check.await_count == 0
def test_worker_liveness_stays_200_when_database_is_down():
client, memory = _worker_client(_DB_DOWN)
response = client.get("/health/live")
assert response.status_code == 200
body = response.json()
assert body["status"] == "alive"
assert body["worker_id"] == "w-test"
assert body["is_shutdown"] is False
assert body["seconds_since_last_poll"] == 0.4
assert memory.health_check.await_count == 0
def test_worker_liveness_stays_200_when_poller_has_stalled():
"""A stale poll cycle is reported, never converted into a restart."""
poller = MagicMock()
poller.worker_id = "w-stalled"
poller.is_shutdown = False
poller.seconds_since_last_poll = 900.0
client, _ = _worker_client(_DB_UP, poller=poller)
response = client.get("/health/live")
assert response.status_code == 200
assert response.json()["seconds_since_last_poll"] == 900.0
def test_worker_liveness_reports_null_poll_age_before_first_cycle():
poller = MagicMock()
poller.worker_id = "w-fresh"
poller.is_shutdown = False
poller.seconds_since_last_poll = None
client, _ = _worker_client(_DB_UP, poller=poller)
assert client.get("/health/live").json()["seconds_since_last_poll"] is None
# ---------------------------------------------------------------------------
# Readiness: still gated on the database
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("path", ["/health", "/health/ready"])
def test_api_readiness_fails_when_database_is_down(path):
client, _ = _api_client(_DB_DOWN)
response = client.get(path)
assert response.status_code == 503
assert response.json()["status"] == "unhealthy"
@pytest.mark.parametrize("path", ["/health", "/health/ready"])
def test_worker_readiness_fails_when_database_is_down(path):
client, _ = _worker_client(_DB_DOWN)
response = client.get(path)
assert response.status_code == 503
assert response.json()["status"] == "unhealthy"
def test_api_health_and_ready_return_the_same_payload():
"""/health is documented as the alias of /health/ready — keep it true."""
client, _ = _api_client(_DB_UP)
health = client.get("/health")
ready = client.get("/health/ready")
assert health.status_code == ready.status_code == 200
assert health.json() == ready.json()
def test_worker_health_and_ready_return_the_same_payload():
client, _ = _worker_client(_DB_UP)
health = client.get("/health")
ready = client.get("/health/ready")
assert health.status_code == ready.status_code == 200
assert health.json() == ready.json() == {**_DB_UP, "worker_id": "w-test", "is_shutdown": False}
# ---------------------------------------------------------------------------
# Poller progress stamp
# ---------------------------------------------------------------------------
def test_poll_age_is_none_until_the_first_cycle_completes():
from hindsight_api.worker import WorkerPoller
poller = WorkerPoller(backend=MagicMock(), worker_id="w-test", executor=AsyncMock())
assert poller.seconds_since_last_poll is None
@pytest.mark.asyncio
async def test_poll_age_is_stamped_by_the_claim_loop():
"""One pass of the loop must refresh the stamp, whether or not it claimed work."""
from hindsight_api.worker import WorkerPoller
poller = WorkerPoller(backend=MagicMock(), worker_id="w-test", executor=AsyncMock(), poll_interval_ms=1)
poller.recover_own_tasks = AsyncMock()
poller.claim_batch = AsyncMock(return_value=[])
poller._log_progress_if_due = AsyncMock()
# Let the loop run a couple of cycles, then signal shutdown.
run = asyncio.create_task(poller.run())
await asyncio.sleep(0.05)
poller._shutdown.set()
await run
assert poller.seconds_since_last_poll is not None
assert poller.seconds_since_last_poll >= 0
@@ -1421,6 +1421,28 @@ async def test_patch_config_rejection_does_not_create_bank(api_client):
await _assert_bank_missing(api_client, test_bank_id)
@pytest.mark.asyncio
async def test_patch_config_rejects_wrong_value_type(api_client):
"""A JSON object in a string-typed field must 400, not be stored (#3218).
Storing it succeeded before, and the bank then failed every consolidation
with ``expected string or bytes-like object, got 'dict'`` from inside prompt
assembly deterministically, so the bank never recovered.
"""
test_bank_id = f"patch_bad_type_{datetime.now().timestamp()}"
response = await api_client.patch(
f"/v1/default/banks/{test_bank_id}/config",
json={"updates": {"observations_mission": {"rules": ["only preferences"], "budget": 3}}},
)
assert response.status_code == 400, response.text
detail = response.json()["detail"]
assert "observations_mission" in detail
assert "must be a string" in detail
await _assert_bank_missing(api_client, test_bank_id)
@pytest.mark.asyncio
async def test_patch_config_does_not_apply_client_field_permissions_to_projected_defaults(
api_client,
@@ -12,12 +12,82 @@ from datetime import datetime, timedelta, timezone
import pytest_asyncio
from hindsight_api.engine.memory_engine import MemoryEngine, _may_need_refresh
from hindsight_api.extensions import (
BankReadContext,
BankReadOperation,
BankWriteContext,
BankWriteOperation,
OperationValidatorExtension,
ValidationResult,
)
def _enc(bank_id: str) -> str:
return urllib.parse.quote(bank_id, safe="")
class _RecordingValidator(OperationValidatorExtension):
"""A validator that records every bank read/write and rejects one operation.
A concrete subclass (not a MagicMock) so every inherited hook including the
async post-hooks the background mental-model refresh worker fires after a page
create is a real coroutine. Only the named operation is rejected; every other
hook (including DELETE_BANK, used by the ``kb_bank`` fixture teardown) accepts.
"""
def __init__(
self,
*,
reject_read: BankReadOperation | None = None,
reject_write: BankWriteOperation | None = None,
reason: str = "operation is forbidden",
) -> None:
super().__init__({})
self._reject_read = reject_read
self._reject_write = reject_write
self._reason = reason
self.read_ops: list[BankReadOperation] = []
self.write_ops: list[BankWriteOperation] = []
async def validate_retain(self, ctx) -> ValidationResult:
return ValidationResult.accept()
async def validate_recall(self, ctx) -> ValidationResult:
return ValidationResult.accept()
async def validate_reflect(self, ctx) -> ValidationResult:
return ValidationResult.accept()
async def validate_bank_read(self, ctx: BankReadContext) -> ValidationResult:
self.read_ops.append(ctx.operation)
if ctx.operation is self._reject_read:
return ValidationResult.reject(self._reason)
return ValidationResult.accept()
async def validate_bank_write(self, ctx: BankWriteContext) -> ValidationResult:
self.write_ops.append(ctx.operation)
if ctx.operation is self._reject_write:
return ValidationResult.reject(self._reason)
return ValidationResult.accept()
def _kb_validator(
*,
reject_read: BankReadOperation | None = None,
reject_write: BankWriteOperation | None = None,
reason: str = "operation is forbidden",
) -> _RecordingValidator:
return _RecordingValidator(reject_read=reject_read, reject_write=reject_write, reason=reason)
def _read_ops(validator: _RecordingValidator) -> list[BankReadOperation]:
return list(validator.read_ops)
def _write_ops(validator: _RecordingValidator) -> list[BankWriteOperation]:
return list(validator.write_ops)
class _Seed:
"""Holds the ids created by the seed fixture for assertions."""
@@ -376,3 +446,248 @@ class TestMoveRenameDelete:
# the backing mental model is gone too
mm = await memory.get_mental_model(bank_id, ids.orders_mm, request_context=request_context)
assert mm is None
class TestAuthorizationReadDenied:
"""A validator that denies a knowledge-base read blocks it with 403 and leaks
nothing knowledge pages render mental-model content, so this is the sharp
edge of #3312 (read-your-neighbour's-synthesized-memories)."""
async def test_tree_denied(self, api_client, kb_bank, memory, monkeypatch):
bank_id, ids = kb_bank
validator = _kb_validator(reject_read=BankReadOperation.GET_KNOWLEDGE_BASE_TREE)
monkeypatch.setattr(memory, "_operation_validator", validator)
resp = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/tree")
assert resp.status_code == 403, resp.text
assert "Orders" not in resp.text
assert _read_ops(validator) == [BankReadOperation.GET_KNOWLEDGE_BASE_TREE]
async def test_get_page_denied(self, api_client, kb_bank, memory, monkeypatch):
bank_id, ids = kb_bank
validator = _kb_validator(reject_read=BankReadOperation.GET_KNOWLEDGE_PAGE)
monkeypatch.setattr(memory, "_operation_validator", validator)
resp = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/pages/{ids.orders}")
assert resp.status_code == 403, resp.text
assert "One row per order." not in resp.text
assert _read_ops(validator) == [BankReadOperation.GET_KNOWLEDGE_PAGE]
async def test_search_denied(self, api_client, kb_bank, memory, monkeypatch):
bank_id, ids = kb_bank
validator = _kb_validator(reject_read=BankReadOperation.SEARCH_KNOWLEDGE_BASE)
monkeypatch.setattr(memory, "_operation_validator", validator)
resp = await api_client.get(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/search",
params={"q": "orders"},
)
assert resp.status_code == 403, resp.text
assert "Orders" not in resp.text
assert _read_ops(validator) == [BankReadOperation.SEARCH_KNOWLEDGE_BASE]
async def test_export_denied_leaks_nothing_and_gates_once(self, api_client, kb_bank, memory, monkeypatch):
bank_id, ids = kb_bank
validator = _kb_validator(reject_read=BankReadOperation.EXPORT_KNOWLEDGE_BASE)
monkeypatch.setattr(memory, "_operation_validator", validator)
resp = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/export")
assert resp.status_code == 403, resp.text
assert "One row per order." not in resp.text
# A single export read gate — the per-page reads never run on a denied path.
assert _read_ops(validator) == [BankReadOperation.EXPORT_KNOWLEDGE_BASE]
class TestAuthorizationWriteDenied:
"""A validator that denies a knowledge-base write blocks it with 403 and leaves
the tree unchanged."""
async def _tree_names(self, api_client, bank_id) -> set[str]:
tree = (await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/tree")).json()
def walk(nodes):
for n in nodes:
yield n["name"]
yield from walk(n.get("children", []))
return set(walk(tree["roots"]))
async def test_create_folder_denied(self, api_client, kb_bank, memory, monkeypatch):
bank_id, ids = kb_bank
before = await self._tree_names(api_client, bank_id)
validator = _kb_validator(reject_write=BankWriteOperation.CREATE_KNOWLEDGE_FOLDER)
monkeypatch.setattr(memory, "_operation_validator", validator)
resp = await api_client.post(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/folders",
json={"name": "Guides", "parent_id": None},
)
assert resp.status_code == 403, resp.text
assert _write_ops(validator) == [BankWriteOperation.CREATE_KNOWLEDGE_FOLDER]
monkeypatch.setattr(memory, "_operation_validator", None)
assert await self._tree_names(api_client, bank_id) == before
async def test_create_page_denied(self, api_client, kb_bank, memory, monkeypatch):
bank_id, ids = kb_bank
before = await self._tree_names(api_client, bank_id)
validator = _kb_validator(reject_write=BankWriteOperation.CREATE_KNOWLEDGE_PAGE)
monkeypatch.setattr(memory, "_operation_validator", validator)
resp = await api_client.post(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/pages",
json={"name": "New page", "source_query": "what is new?"},
)
assert resp.status_code == 403, resp.text
# Rejected before the backing mental model is created — a single write hook.
assert _write_ops(validator) == [BankWriteOperation.CREATE_KNOWLEDGE_PAGE]
monkeypatch.setattr(memory, "_operation_validator", None)
assert await self._tree_names(api_client, bank_id) == before
async def test_rename_denied(self, api_client, kb_bank, memory, monkeypatch):
bank_id, ids = kb_bank
validator = _kb_validator(reject_write=BankWriteOperation.RENAME_KNOWLEDGE_NODE)
monkeypatch.setattr(memory, "_operation_validator", validator)
resp = await api_client.patch(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.policies}",
json={"name": "Compliance"},
)
assert resp.status_code == 403, resp.text
assert _write_ops(validator) == [BankWriteOperation.RENAME_KNOWLEDGE_NODE]
monkeypatch.setattr(memory, "_operation_validator", None)
assert "Policies" in await self._tree_names(api_client, bank_id)
async def test_move_denied(self, api_client, kb_bank, memory, monkeypatch):
bank_id, ids = kb_bank
validator = _kb_validator(reject_write=BankWriteOperation.MOVE_KNOWLEDGE_NODE)
monkeypatch.setattr(memory, "_operation_validator", validator)
resp = await api_client.patch(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.loose}",
json={"parent_id": ids.policies},
)
assert resp.status_code == 403, resp.text
assert _write_ops(validator) == [BankWriteOperation.MOVE_KNOWLEDGE_NODE]
async def test_update_page_denied(self, api_client, kb_bank, memory, monkeypatch):
bank_id, ids = kb_bank
validator = _kb_validator(reject_write=BankWriteOperation.UPDATE_KNOWLEDGE_PAGE)
monkeypatch.setattr(memory, "_operation_validator", validator)
resp = await api_client.patch(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.orders}",
json={"source_query": "changed"},
)
assert resp.status_code == 403, resp.text
# Rejected before touching the backing mental model — a single write hook.
assert _write_ops(validator) == [BankWriteOperation.UPDATE_KNOWLEDGE_PAGE]
async def test_delete_denied(self, api_client, kb_bank, memory, monkeypatch):
bank_id, ids = kb_bank
validator = _kb_validator(reject_write=BankWriteOperation.DELETE_KNOWLEDGE_NODE)
monkeypatch.setattr(memory, "_operation_validator", validator)
resp = await api_client.delete(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.runbooks}")
assert resp.status_code == 403, resp.text
assert _write_ops(validator) == [BankWriteOperation.DELETE_KNOWLEDGE_NODE]
monkeypatch.setattr(memory, "_operation_validator", None)
assert "Runbooks" in await self._tree_names(api_client, bank_id)
async def test_denied_create_leaves_no_bank_behind(self, api_client, memory, request_context, monkeypatch):
"""An unauthorized create must not lazily provision the target bank."""
bank_id = f"kb-denied-create-{uuid.uuid4().hex[:8]}"
validator = _kb_validator(reject_write=BankWriteOperation.CREATE_KNOWLEDGE_FOLDER)
monkeypatch.setattr(memory, "_operation_validator", validator)
resp = await api_client.post(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/folders",
json={"name": "Guides", "parent_id": None},
)
assert resp.status_code == 403, resp.text
monkeypatch.setattr(memory, "_operation_validator", None)
assert await memory.get_bank_profile(bank_id, request_context=request_context, create_if_missing=False) is None
class TestAuthorizationSuccessHookCounts:
"""Successful knowledge-base routes invoke exactly one validator hook — the
knowledge-base operation and never the nested mental-model hooks."""
async def test_reads_gate_once(self, api_client, kb_bank, memory, monkeypatch):
bank_id, ids = kb_bank
validator = _kb_validator()
monkeypatch.setattr(memory, "_operation_validator", validator)
validator.read_ops.clear()
await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/tree")
assert _read_ops(validator) == [BankReadOperation.GET_KNOWLEDGE_BASE_TREE]
validator.read_ops.clear()
await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/pages/{ids.orders}")
assert _read_ops(validator) == [BankReadOperation.GET_KNOWLEDGE_PAGE]
validator.read_ops.clear()
await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/search", params={"q": "orders"})
assert _read_ops(validator) == [BankReadOperation.SEARCH_KNOWLEDGE_BASE]
async def test_export_gates_once_and_suppresses_nested_reads(self, api_client, kb_bank, memory, monkeypatch):
bank_id, ids = kb_bank
validator = _kb_validator()
monkeypatch.setattr(memory, "_operation_validator", validator)
validator.read_ops.clear()
resp = await api_client.get(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/export")
assert resp.status_code == 200, resp.text
# Exactly one gate for the whole bundle; the per-page reads run under it.
assert _read_ops(validator) == [BankReadOperation.EXPORT_KNOWLEDGE_BASE]
async def test_create_page_gates_once_without_nested_mental_model_write(
self, kb_bank, memory, request_context, monkeypatch
):
# Tested at the engine level: the HTTP route additionally schedules an
# async refresh whose background worker later writes the generated content
# (a separate, legitimately metered UPDATE_MENTAL_MODEL). Here we assert the
# synchronous create in isolation — the backing CREATE_MENTAL_MODEL is
# suppressed, so the KB write is the only bank_write hook.
bank_id, ids = kb_bank
validator = _kb_validator()
monkeypatch.setattr(memory, "_operation_validator", validator)
validator.write_ops.clear()
node = await memory.create_knowledge_page(
bank_id,
"Fresh page",
"what is fresh?",
"content",
request_context=request_context,
)
assert node is not None
assert _write_ops(validator) == [BankWriteOperation.CREATE_KNOWLEDGE_PAGE]
async def test_writes_gate_once(self, api_client, kb_bank, memory, monkeypatch):
bank_id, ids = kb_bank
validator = _kb_validator()
monkeypatch.setattr(memory, "_operation_validator", validator)
validator.write_ops.clear()
await api_client.post(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/folders", json={"name": "Guides"})
assert _write_ops(validator) == [BankWriteOperation.CREATE_KNOWLEDGE_FOLDER]
validator.write_ops.clear()
await api_client.patch(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.policies}",
json={"name": "Compliance"},
)
assert _write_ops(validator) == [BankWriteOperation.RENAME_KNOWLEDGE_NODE]
validator.write_ops.clear()
await api_client.patch(
f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.orders}",
json={"tags": ["type:runbook"]},
)
assert _write_ops(validator) == [BankWriteOperation.UPDATE_KNOWLEDGE_PAGE]
validator.write_ops.clear()
await api_client.delete(f"/v1/default/banks/{_enc(bank_id)}/knowledge-base/nodes/{ids.billing}")
assert _write_ops(validator) == [BankWriteOperation.DELETE_KNOWLEDGE_NODE]
class TestAuthorizationDisabled:
"""Without an operation validator (OSS default), knowledge-base routes are
unauthenticated-by-tenant and work exactly as before."""
async def test_engine_calls_do_not_raise(self, memory, request_context):
assert memory._operation_validator is None
bank_id = f"kb-noauth-{uuid.uuid4().hex[:8]}"
folder = await memory.create_knowledge_folder(bank_id, "Docs", request_context=request_context)
nodes = await memory.list_knowledge_nodes(bank_id=bank_id, request_context=request_context)
assert folder["id"] in {n["id"] for n in nodes}
export = await memory.export_knowledge_base(bank_id=bank_id, request_context=request_context)
assert any(n["id"] == folder["id"] for n in export.nodes)
await memory.delete_bank(bank_id, request_context=request_context)
@@ -0,0 +1,120 @@
"""Backend dispatch for the knowledge-page BM25 arm (issue #3268).
``search_knowledge_pages`` used to hard-code the native tsvector SQL
(``ts_rank_cd`` / ``@@``) for every text-search backend, so it 500'd on
``pg_search`` / ``pg_textsearch`` / ``vchord`` where ``mental_models.search_vector``
is not a tsvector. These tests pin the per-backend SQL the read dispatcher emits,
and the write-side ``search_vector`` tokenization the ``mental_models`` insert/
update reuse from the memory-recall path. They need no live extension because they
assert on the generated SQL, the way ``test_multilingual_bm25`` does.
"""
from types import SimpleNamespace
from hindsight_api._text_search import mental_models_text_document
from hindsight_api.engine.db.ops_postgresql import pg_search_vector_expr
from hindsight_api.engine.sql.postgresql import KnowledgeBm25Arm, knowledge_bm25_arm
def _cfg(ext: str) -> SimpleNamespace:
return SimpleNamespace(text_search_extension=ext, text_search_extension_native_language="english")
def _arm(ext: str) -> KnowledgeBm25Arm:
return knowledge_bm25_arm(ext, table_alias="mm", text_param="$3")
def test_native_uses_tsvector_operators():
arm = _arm("native")
# The mental_models tsvector is generated with the 'english' config, so the
# query must use 'english' regardless of the configured native language.
assert "ts_rank_cd(mm.search_vector, websearch_to_tsquery('english', $3))" in arm.score_expr
assert arm.match_filter == "AND mm.search_vector @@ websearch_to_tsquery('english', $3)"
def test_pgroonga_uses_multilingual_expression_index():
arm = _arm("pgroonga")
assert arm.score_expr == "pgroonga_score(mm.tableoid, mm.ctid)"
assert arm.match_filter == "AND (COALESCE(mm.name, '') || ' ' || mm.content) &@~ pgroonga_query_escape($3)"
# pgroonga_score() reads 0 off any plan that did not use the pgroonga index,
# so the ordering carries a tiebreak instead of collapsing to input order.
assert arm.order_by == "pgroonga_score(mm.tableoid, mm.ctid) DESC, mm.id"
def test_pgroonga_filter_repeats_the_indexed_expression_verbatim():
"""The expression index is only selectable when the query repeats its
expression exactly so both sides must come from the shared helper."""
assert mental_models_text_document("mm") in _arm("pgroonga").match_filter
assert mental_models_text_document() == "(COALESCE(name, '') || ' ' || content)"
def test_pg_search_uses_paradedb_over_base_columns():
arm = _arm("pg_search")
assert arm.score_expr == "paradedb.score(mm.id)"
assert "mm.id @@@ paradedb.boolean(should => ARRAY[" in arm.match_filter
assert "paradedb.match('name', $3)" in arm.match_filter
assert "paradedb.match('content', $3)" in arm.match_filter
# Must not fall back to the native tsvector function.
assert "ts_rank_cd" not in arm.score_expr
assert "ts_rank_cd" not in arm.order_by
def test_pg_textsearch_ranks_content_by_bm25_distance():
arm = _arm("pg_textsearch")
# `<@>` is a distance (lower = closer): order ASC, negate for the score.
assert arm.order_by == "mm.content <@> to_bm25query($3, 'idx_mental_models_text_search') ASC"
assert arm.score_expr == "-(mm.content <@> to_bm25query($3, 'idx_mental_models_text_search'))"
# It ranks every row, so there is no boolean match gate.
assert arm.match_filter == ""
assert "ts_rank_cd" not in arm.order_by
def test_vchord_ranks_over_bm25vector_search_vector():
arm = _arm("vchord")
# Negated <&> distance over the bm25vector column and the mental_models index,
# gated on a positive score — the same operator build_bm25_arm uses.
assert (
arm.score_expr
== "-(mm.search_vector <&> to_bm25query('idx_mental_models_text_search', tokenize($3, 'llmlingua2')))"
)
assert arm.order_by.endswith(" DESC")
assert arm.match_filter.endswith(" > 0")
assert "ts_rank_cd" not in arm.score_expr
def test_text_param_and_alias_are_threaded_through():
arm = knowledge_bm25_arm("pg_search", table_alias="kbm", text_param="$7")
assert "paradedb.score(kbm.id)" == arm.score_expr
assert "kbm.id @@@" in arm.match_filter
assert "paradedb.match('name', $7)" in arm.match_filter
# --- write side: search_vector tokenization for mental_models ---------------
# mental_models is a two-column (name + content) table whose native search_vector
# is a GENERATED column, so only vchord needs an inline write (native_inline=False).
def test_mm_write_tokenizes_only_for_vchord():
for ext in ("native", "pgroonga", "pg_search", "pg_textsearch"):
assert (
pg_search_vector_expr(_cfg(ext), text_col="$3", context_col="$5", signals_col=None, native_inline=False)
is None
), f"{ext} must leave mental_models.search_vector unwritten"
vchord = pg_search_vector_expr(
_cfg("vchord"), text_col="$3", context_col="$5", signals_col=None, native_inline=False
)
assert vchord == "tokenize(COALESCE($3, '') || ' ' || COALESCE($5, ''), 'llmlingua2')::bm25_catalog.bm25vector"
def test_memory_units_default_expr_is_unchanged():
# The recall/insert path keeps its three-column, native-inline behaviour.
assert pg_search_vector_expr(_cfg("native")) == (
"to_tsvector('english'::regconfig, "
"COALESCE(text, '') || ' ' || COALESCE(context, '') || ' ' || COALESCE(text_signals, ''))"
)
assert pg_search_vector_expr(_cfg("vchord")) == (
"tokenize(COALESCE(text, '') || ' ' || COALESCE(context, '') || ' ' || COALESCE(text_signals, ''), "
"'llmlingua2')::bm25_catalog.bm25vector"
)
assert pg_search_vector_expr(_cfg("pg_search")) is None
@@ -168,21 +168,28 @@ async def test_append_after_zero_fact_header_slice_skips_unchanged_history(
bank_id = f"test_large_append_zero_fact_header_{_ts()}"
document_id = "chat-session-zero-fact-header"
header = "# Stable Chat Session\nSession id: session-1\nCreated at: 2026-07-09T00:00:00Z"
# Long enough to span several native chunks: slices are cut on chunk
# boundaries, so the history has to exceed a few retain_chunk_size windows
# for this test to see more than a couple of sub-batches.
history = "\n".join(
f"[role: user] turn {turn}: "
f"{'UNCHANGED_HEAD' if turn == 0 else 'UNCHANGED_MIDDLE' if turn == 30 else 'historical'} "
"alpha bravo charlie delta echo foxtrot golf hotel india juliet"
for turn in range(60)
for turn in range(160)
)
initial_body = f"{header}\n\n{history}"
tail_marker = "NEW_APPEND_ONLY_TAIL"
appended_body = f"{initial_body}\n[role: user] turn 60: {tail_marker} alpha bravo charlie delta"
# Slices are cut on native chunk boundaries, so a tiny token budget yields
# one sub-batch per chunk of the body — the header lands alone in the first
# one, followed by the history slices this test replays.
split = _split_contents_into_sub_batches(
[{"content": initial_body, "document_id": document_id}],
100,
chunk_size=3000,
)
assert len(split.sub_batches) > 3
assert len(split.sub_batches) > 2
assert "[role:" not in split.sub_batches[0][0]["content"]
extracted_contents: list[str] = []
+12 -10
View File
@@ -63,15 +63,15 @@ class TestCapLinksPerUnit:
def test_under_cap_unchanged(self):
links = [
("unit_a", "unit_x", "temporal", 0.9),
("unit_a", "unit_y", "temporal", 0.8),
("unit_a", "unit_x", "temporal", 0.9, None),
("unit_a", "unit_y", "temporal", 0.8, None),
]
result = _cap_links_per_unit(links, max_per_unit=5)
assert len(result) == 2
def test_caps_to_max_per_unit(self):
# Create 30 links from the same unit with descending weights
links = [("unit_a", f"unit_{i}", "temporal", 1.0 - i * 0.01) for i in range(30)]
links = [("unit_a", f"unit_{i}", "temporal", 1.0 - i * 0.01, None) for i in range(30)]
result = _cap_links_per_unit(links, max_per_unit=10)
assert len(result) == 10
# Should keep the highest-weight links
@@ -80,8 +80,8 @@ class TestCapLinksPerUnit:
assert weights[0] == 1.0 # Highest weight kept
def test_caps_independently_per_unit(self):
links_a = [("unit_a", f"target_{i}", "temporal", 0.9 - i * 0.01) for i in range(10)]
links_b = [("unit_b", f"target_{i}", "temporal", 0.8 - i * 0.01) for i in range(10)]
links_a = [("unit_a", f"target_{i}", "temporal", 0.9 - i * 0.01, None) for i in range(10)]
links_b = [("unit_b", f"target_{i}", "temporal", 0.8 - i * 0.01, None) for i in range(10)]
result = _cap_links_per_unit(links_a + links_b, max_per_unit=5)
# 5 from unit_a + 5 from unit_b
assert len(result) == 10
@@ -91,14 +91,14 @@ class TestCapLinksPerUnit:
assert len(from_b) == 5
def test_default_max_is_temporal_constant(self):
links = [("unit_a", f"target_{i}", "temporal", 1.0 - i * 0.01) for i in range(50)]
links = [("unit_a", f"target_{i}", "temporal", 1.0 - i * 0.01, None) for i in range(50)]
result = _cap_links_per_unit(links)
assert len(result) == MAX_TEMPORAL_LINKS_PER_UNIT
def test_preserves_tuple_structure(self):
links = [("from_id", "to_id", "temporal", 0.95)]
links = [("from_id", "to_id", "temporal", 0.95, "entity_id")]
result = _cap_links_per_unit(links, max_per_unit=5)
assert result[0] == ("from_id", "to_id", "temporal", 0.95)
assert result[0] == ("from_id", "to_id", "temporal", 0.95, "entity_id")
class TestComputeSemanticLinksWithinBatch:
@@ -148,6 +148,7 @@ class TestComputeSemanticLinksWithinBatch:
for lnk in links:
assert lnk[2] == "semantic"
assert lnk[3] >= 0.99 # near-1.0 similarity
assert lnk[4] is None # no entity_id
def test_orthogonal_embeddings_no_links(self):
"""Orthogonal embeddings should have similarity=0 (below 0.7 threshold)."""
@@ -218,12 +219,13 @@ class TestComputeSemanticLinksWithinBatch:
threshold=DEFAULT_SEMANTIC_LINK_MIN_SIMILARITY,
)
for lnk in links:
assert len(lnk) == 4
from_id, to_id, link_type, weight = lnk
assert len(lnk) == 5
from_id, to_id, link_type, weight, entity_id = lnk
assert isinstance(from_id, str)
assert isinstance(to_id, str)
assert link_type == "semantic"
assert 0.0 <= weight <= 1.0
assert entity_id is None
class TestComputeSemanticLinksAnnPgBouncerSafety:
@@ -0,0 +1,67 @@
"""Regression test: list_banks must consult the per-bank capability with the
*current row's* bank id, and source fact_count from the store when that bank
keeps its memories outside SQL.
Bug (introduced with per-bank store capabilities, #3350): list_banks called
``_store.writes_memory_rows_in_sql_for(bank_id)`` with a bare ``bank_id`` name
that is not in scope inside the per-row loop (the row's id is ``row["bank_id"]``).
Because the argument is evaluated before the call, this raised
``NameError: name 'bank_id' is not defined`` for *every* org on the very first
bank i.e. GET /banks 500'd outright — regardless of the store's capability.
This test swaps in a store that reports ``writes_memory_rows_in_sql_for -> False``
(the non-SQL branch the feature added), and asserts list_banks (a) does not raise,
(b) calls the capability + count_memories with the correct per-bank id, and
(c) surfaces the store's live count as fact_count.
Runs via: uv run pytest tests/test_list_banks_non_sql_store.py -v
"""
from __future__ import annotations
import pytest
import hindsight_api.engine.memories as memories_mod
from hindsight_api.models import RequestContext
class _NonSqlStore:
"""A store that keeps memory rows outside SQL: list_banks must count via the store."""
def __init__(self):
self.capability_calls: list[str] = []
self.count_calls: list[str] = []
def writes_memory_rows_in_sql_for(self, bank_id: str) -> bool:
self.capability_calls.append(bank_id)
return False
async def count_memories(self, *, conn, fq_table, bank_id: str) -> dict:
self.count_calls.append(bank_id)
return {"world": 7}
@pytest.mark.asyncio
async def test_list_banks_counts_via_store_for_non_sql_bank(memory, monkeypatch):
bank_id = "list_banks_non_sql_bank"
request_context = RequestContext(api_key=None, api_key_id=None, tenant_id=None, internal=False)
store = _NonSqlStore()
monkeypatch.setattr(memories_mod, "get_memories", lambda: store)
try:
await memory.get_bank_profile(bank_id, request_context=request_context)
# Must not raise NameError; must reach the store's non-SQL count path.
banks = await memory.list_banks(request_context=request_context)
entry = next((b for b in banks if b["bank_id"] == bank_id), None)
assert entry is not None, f"bank {bank_id!r} not present in list_banks output"
# The capability + count were consulted with the row's real bank id.
assert bank_id in store.capability_calls
assert bank_id in store.count_calls
# fact_count came from the store (sum of the per-type counts), not the empty SQL join.
assert entry["fact_count"] == 7
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -0,0 +1,198 @@
"""Structured output via a forced tool call on the LiteLLM-backed providers.
Covers the flag (HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL) end to end:
config parsing, the config -> LLMProvider -> LiteLLMLLM wiring, the request shape
it produces, and the response path that substitutes the tool call's arguments for
the message content. Motivation: Bedrock Claude rejects the ``response_format``
route outright (``output_config.format: Extra inputs are not permitted``, #3300)
while accepting the identical schema as a tool.
"""
from typing import Any
from unittest.mock import AsyncMock
import pytest
from pydantic import BaseModel
from hindsight_api.config import ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL, HindsightConfig
from hindsight_api.engine.llm_wrapper import LLMConfig
from hindsight_api.engine.providers.litellm_llm import LiteLLMLLM
_MESSAGES = [{"role": "user", "content": "hi"}]
class _Facts(BaseModel):
facts: list[str]
def _make_litellm(*, forced_tool: bool) -> LiteLLMLLM:
return LiteLLMLLM(
provider="bedrock",
api_key="",
base_url="",
model="bedrock/au.anthropic.claude-haiku-4-5-20251001-v1:0",
structured_output_forced_tool=forced_tool,
)
class _Function:
def __init__(self, name: str, arguments: Any):
self.name = name
self.arguments = arguments
class _ToolCall:
def __init__(self, name: str, arguments: Any):
self.id = "call_1"
self.function = _Function(name, arguments)
class _Message:
def __init__(self, content: str | None, tool_calls: list[_ToolCall] | None = None):
self.content = content
self.tool_calls = tool_calls
class _Choice:
def __init__(self, message: _Message, finish_reason: str):
self.message = message
self.finish_reason = finish_reason
class _Response:
def __init__(self, message: _Message, finish_reason: str = "tool_calls"):
self.choices = [_Choice(message, finish_reason)]
self.usage = None
async def _call_capturing_request(llm: LiteLLMLLM, response: _Response) -> dict[str, Any]:
"""Run ``call`` against a stubbed completion and return the request kwargs."""
completion = AsyncMock(return_value=response)
llm._acompletion = completion # type: ignore[method-assign]
result = await llm.call(messages=_MESSAGES, response_format=_Facts, max_retries=0)
return {"kwargs": completion.await_args.kwargs, "result": result}
# ── config ───────────────────────────────────────────────────────────────────
def test_forced_tool_defaults_off(monkeypatch):
monkeypatch.delenv(ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL, raising=False)
assert HindsightConfig.from_env().llm_structured_output_forced_tool is False
def test_forced_tool_can_be_enabled(monkeypatch):
monkeypatch.setenv(ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL, "true")
assert HindsightConfig.from_env().llm_structured_output_forced_tool is True
@pytest.mark.parametrize("value", ["", "yes", "tru", "enabled"])
def test_forced_tool_rejects_ambiguous_values(monkeypatch, value):
monkeypatch.setenv(ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL, value)
with pytest.raises(ValueError, match=ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL):
HindsightConfig.from_env()
def test_llm_config_threads_flag_to_provider_impl():
"""LLMConfig -> create_llm_provider -> LiteLLMLLM carries the flag.
Without this bridge the env var is inert: the provider silently keeps the
default ``response_format`` transport.
"""
llm = LLMConfig(
provider="bedrock",
api_key="",
base_url="",
model="au.anthropic.claude-haiku-4-5-20251001-v1:0",
structured_output_forced_tool=True,
)
assert llm._provider_impl.structured_output_forced_tool is True
# ── request shape ────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_forced_tool_replaces_response_format_with_a_forced_tool():
llm = _make_litellm(forced_tool=True)
response = _Response(_Message(None, [_ToolCall("structured_response", '{"facts": ["the sky is blue"]}')]))
captured = await _call_capturing_request(llm, response)
kwargs = captured["kwargs"]
assert "response_format" not in kwargs
assert kwargs["tool_choice"] == {"type": "function", "function": {"name": "structured_response"}}
assert len(kwargs["tools"]) == 1
function = kwargs["tools"][0]["function"]
assert function["name"] == "structured_response"
assert function["parameters"] == _Facts.model_json_schema()
assert captured["result"] == _Facts(facts=["the sky is blue"])
@pytest.mark.asyncio
async def test_flag_off_keeps_response_format():
llm = _make_litellm(forced_tool=False)
response = _Response(_Message('{"facts": ["the sky is blue"]}'), finish_reason="stop")
captured = await _call_capturing_request(llm, response)
kwargs = captured["kwargs"]
assert "tools" not in kwargs
assert "tool_choice" not in kwargs
assert kwargs["response_format"]["json_schema"]["schema"] == _Facts.model_json_schema()
assert captured["result"] == _Facts(facts=["the sky is blue"])
@pytest.mark.asyncio
async def test_plain_calls_are_untouched_by_the_flag():
"""No ``response_format`` -> no tool is forced, so free-text calls still work."""
llm = _make_litellm(forced_tool=True)
completion = AsyncMock(return_value=_Response(_Message("hello"), finish_reason="stop"))
llm._acompletion = completion # type: ignore[method-assign]
result = await llm.call(messages=_MESSAGES, max_retries=0)
assert result == "hello"
assert "tools" not in completion.await_args.kwargs
# ── response path ────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_forced_tool_accepts_already_decoded_arguments():
"""Some providers hand back decoded arguments instead of a JSON string."""
llm = _make_litellm(forced_tool=True)
response = _Response(_Message(None, [_ToolCall("structured_response", {"facts": ["grass is green"]})]))
captured = await _call_capturing_request(llm, response)
assert captured["result"] == _Facts(facts=["grass is green"])
@pytest.mark.asyncio
async def test_falls_back_to_text_when_the_tool_call_is_missing():
"""A gateway that drops ``tool_choice`` must not hard-fail the call."""
llm = _make_litellm(forced_tool=True)
response = _Response(_Message('{"facts": ["parsed from text"]}'), finish_reason="stop")
captured = await _call_capturing_request(llm, response)
assert captured["result"] == _Facts(facts=["parsed from text"])
@pytest.mark.asyncio
async def test_skip_validation_returns_the_raw_tool_arguments():
llm = _make_litellm(forced_tool=True)
response = _Response(_Message(None, [_ToolCall("structured_response", '{"facts": ["raw"]}')]))
llm._acompletion = AsyncMock(return_value=response) # type: ignore[method-assign]
result = await llm.call(
messages=_MESSAGES,
response_format=_Facts,
skip_validation=True,
max_retries=0,
)
assert result == {"facts": ["raw"]}
@@ -116,6 +116,39 @@ def test_dump_pydantic_config_with_separate_contents(monkeypatch, caplog):
assert "gemini hi" in caplog.text # content preview
# ── force: always-on structural profile for deterministic rejections ───────────
def test_force_dumps_structural_profile_when_flag_off(monkeypatch, caplog):
"""A forced dump (deterministic 400) logs even with the opt-in flag off, but
carries only the structural profile config + per-part sizes, no previews."""
_disable(monkeypatch)
request = {"messages": [{"role": "user", "content": "secret memory content"}]}
with caplog.at_level(logging.ERROR):
_dump(err=SimpleNamespace(status_code=400), request=request, force=True)
assert "[LLM_4XX_DUMP]" in caplog.text
assert '"chars": 21' in caplog.text # per-part size is logged
assert "secret memory content" not in caplog.text # but user content is not
assert "preview" not in caplog.text
def test_force_is_still_noop_on_non_4xx(monkeypatch, caplog):
"""force bypasses the opt-in flag, not the 4xx gate."""
_disable(monkeypatch)
with caplog.at_level(logging.ERROR):
_dump(err=SimpleNamespace(status_code=500), request={"messages": []}, force=True)
assert "[LLM_4XX_DUMP]" not in caplog.text
def test_force_includes_previews_when_flag_also_on(monkeypatch, caplog):
"""With the opt-in flag on, a forced dump still gets the full preview."""
_enable(monkeypatch)
request = {"messages": [{"role": "user", "content": "visible content"}]}
with caplog.at_level(logging.ERROR):
_dump(err=SimpleNamespace(status_code=400), request=request, force=True)
assert '"preview": "visible content"' in caplog.text
# ── safety: truncation + never-raise ───────────────────────────────────────────
@@ -195,6 +195,7 @@ def _make_router_provider(config: dict[str, Any], mock_router: Any) -> LiteLLMRo
provider.reasoning_effort = "low"
provider.timeout = 300.0
provider._default_headers = {}
provider.structured_output_forced_tool = False
provider.config = config
provider._litellm = fake_litellm
provider._router = mock_router
@@ -151,6 +151,9 @@ class TestOperationCleanupJob:
engine = MagicMock()
engine._backend = backend
engine._tenant_extension.list_tenants = AsyncMock(return_value=[SimpleNamespace(schema=s) for s in tenants])
# The sweep purges expired export archives before pruning each schema's rows;
# stub it as a no-op so these tests stay focused on discovery + prune scoping.
engine.purge_expired_export_archives = AsyncMock(return_value=0)
return engine, backend, conn
@staticmethod
@@ -601,6 +601,59 @@ async def test_maintenance_passes_are_optional(restore_default_store):
await store.record_unit_entities(conn=None, ops=None, fq_table=None, unit_ids=["u"], entity_ids=["e"])
# ---------------------------------------------------------------------------
# Per-bank store capabilities. A store may route different banks to different
# backends, so every BANK-SCOPED call site asks per bank —
# writes_memory_rows_in_sql_for(bank_id) / owns_document_store_for(bank_id) —
# rather than reading the process-global class attribute. The class attribute
# stays the single-store default the _for methods fall back to.
# ---------------------------------------------------------------------------
def test_per_bank_capability_defaults_to_the_class_attribute():
"""A single-store extension needs no override: the _for methods return the class attr, so
every existing store keeps its exact behaviour for every bank."""
pg = PostgresMemories({})
assert (pg.writes_memory_rows_in_sql, pg.owns_document_store) == (True, False)
assert pg.writes_memory_rows_in_sql_for("any-bank") is True
assert pg.owns_document_store_for("any-bank") is False
mem = InMemoryMemories({}) # owns its rows AND its document store
assert mem.writes_memory_rows_in_sql_for("any-bank") is False
assert mem.owns_document_store_for("any-bank") is True
def test_a_store_answers_capabilities_per_bank():
"""The point of the _for methods: a store that keeps some banks in SQL and others in a
separate store answers PER BANK, so mixed banks in one process each take the right path."""
class PerBankStore(InMemoryMemories):
name = "per-bank"
# The loop-level class attr stays False so cross-store txn recovery still runs; the
# per-bank answer is what every bank-scoped site consults.
writes_memory_rows_in_sql = False
def __init__(self, config=None):
super().__init__(config)
self.sql_banks = {"legacy-bank"}
def writes_memory_rows_in_sql_for(self, bank_id):
return bank_id in self.sql_banks
def owns_document_store_for(self, bank_id):
return bank_id not in self.sql_banks
store = PerBankStore({})
# A SQL-backed bank looks like Postgres (host does inline SQL, keeps documents in SQL)...
assert store.writes_memory_rows_in_sql_for("legacy-bank") is True
assert store.owns_document_store_for("legacy-bank") is False
# ...a store-backed bank owns its rows and its document store.
assert store.writes_memory_rows_in_sql_for("new-bank") is False
assert store.owns_document_store_for("new-bank") is True
# The process-level gate (cross-store recovery loop) still fires off the class attr.
assert store.writes_memory_rows_in_sql is False
# ---------------------------------------------------------------------------
# Interface conformance: the stub must stay a COMPLETE, signature-compatible
# implementation of every MemoriesExtension method. This is the guard that keeps
@@ -1,153 +0,0 @@
"""Regression for the ``memory_links`` entity-schema drop (``c1e7a9d3f5b2``).
Entity edges stopped being materialized in ``memory_links`` once retain moved
memory-to-entity associations to ``unit_entities`` and the read paths (the /graph
endpoint and recall) began deriving entity edges from that table. Migration
``e9b2c7d1f3a4`` deleted the stored entity rows; this migration removes the
now-dead entity *schema* the ``entity_id`` column and FK, the entity index,
``link_type = 'entity'`` from the CHECK, and the ``entity_id`` term in the
function-based unique index (which collapses to ``(from_unit_id, to_unit_id,
link_type)``).
Uses a dedicated pg0 instance (mirrors test_migration_drop_access_count) so it
controls exactly which migrations have run and never stamps the shared test
instance.
"""
import asyncio
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, text
# One module-scoped pg0 instance shared across tests; pin the module to a single
# xdist worker so concurrent workers don't race to provision the same instance
# or re-migrate a DB another worker is reading.
pytestmark = pytest.mark.xdist_group("migration-drop-memory-links-entity-pg0")
_SCRIPT_LOCATION = str(Path(__file__).parent.parent / "hindsight_api" / "alembic")
_DROP_REVISION = "c1e7a9d3f5b2"
# Revision immediately before the drop.
_PRE_DROP_REVISION = "e4a7c1b9d2f6"
def _alembic_cfg(db_url: str) -> Config:
cfg = Config()
cfg.set_main_option("script_location", _SCRIPT_LOCATION)
cfg.set_main_option("sqlalchemy.url", db_url)
cfg.set_main_option("prepend_sys_path", ".")
cfg.set_main_option("path_separator", "os")
return cfg
def _columns(conn, table: str) -> set[str]:
return {
r[0]
for r in conn.execute(
text("SELECT column_name FROM information_schema.columns WHERE table_name = :t"),
{"t": table},
)
}
def _index_exists(conn, name: str) -> bool:
return bool(conn.execute(text("SELECT 1 FROM pg_indexes WHERE indexname = :n"), {"n": name}).scalar())
def _index_def(conn, name: str) -> str:
return conn.execute(text("SELECT indexdef FROM pg_indexes WHERE indexname = :n"), {"n": name}).scalar() or ""
def _link_type_check_def(conn) -> str:
return (
conn.execute(
text("SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = 'memory_links_link_type_check'")
).scalar()
or ""
)
@pytest.fixture(scope="module")
def head_db_url():
"""pg0 instance migrated to head (includes the entity-schema drop)."""
from hindsight_api.pg0 import EmbeddedPostgres
# port=None lets pg0 auto-assign a free port; a hardcoded port is not xdist-safe.
pg0 = EmbeddedPostgres(name="hindsight-drop-ml-entity-test", port=None)
loop = asyncio.new_event_loop()
try:
url = loop.run_until_complete(pg0.ensure_running())
finally:
loop.close()
command.upgrade(_alembic_cfg(url), "heads")
return url
def test_entity_schema_is_gone(head_db_url):
engine = create_engine(head_db_url)
try:
with engine.connect() as conn:
assert "entity_id" not in _columns(conn, "memory_links"), (
"memory_links.entity_id still exists at head — entity edges are derived from unit_entities"
)
assert not _index_exists(conn, "idx_memory_links_entity"), "the entity index should be gone with the column"
finally:
engine.dispose()
def test_unique_index_is_three_columns(head_db_url):
engine = create_engine(head_db_url)
try:
with engine.connect() as conn:
definition = _index_def(conn, "idx_memory_links_unique").lower()
assert definition, "idx_memory_links_unique is missing"
assert "unique" in definition
for col in ("from_unit_id", "to_unit_id", "link_type"):
assert col in definition, f"{col} missing from idx_memory_links_unique"
# The old expression key coalesced a nullable entity_id; both must be gone.
assert "entity_id" not in definition
assert "coalesce" not in definition
finally:
engine.dispose()
def test_check_rejects_entity_keeps_causal(head_db_url):
engine = create_engine(head_db_url)
try:
with engine.connect() as conn:
definition = _link_type_check_def(conn).lower()
assert definition, "memory_links_link_type_check is missing"
assert "'entity'" not in definition, "CHECK should no longer permit link_type = 'entity'"
for keep in ("'temporal'", "'semantic'", "'caused_by'", "'causes'", "'enables'", "'prevents'"):
assert keep in definition, f"CHECK dropped a still-supported link_type {keep}"
finally:
engine.dispose()
def test_downgrade_restores_entity_schema(head_db_url):
"""Downgrade restores the former schema shape (column, index, CHECK), and
re-upgrading removes it again so the migration is not a one-way door."""
cfg = _alembic_cfg(head_db_url)
engine = create_engine(head_db_url)
try:
command.downgrade(cfg, _PRE_DROP_REVISION)
with engine.connect() as conn:
assert "entity_id" in _columns(conn, "memory_links"), "downgrade did not restore memory_links.entity_id"
assert _index_exists(conn, "idx_memory_links_entity")
assert "'entity'" in _link_type_check_def(conn).lower()
assert "entity_id" in _index_def(conn, "idx_memory_links_unique").lower()
command.upgrade(cfg, _DROP_REVISION)
with engine.connect() as conn:
assert "entity_id" not in _columns(conn, "memory_links")
assert not _index_exists(conn, "idx_memory_links_entity")
assert "'entity'" not in _link_type_check_def(conn).lower()
assert "entity_id" not in _index_def(conn, "idx_memory_links_unique").lower()
finally:
# Leave the module fixture's instance at head for any later user.
command.upgrade(cfg, "heads")
engine.dispose()
@@ -33,6 +33,7 @@ async def _insert_memory(
text: str,
fact_type: str = "experience",
document_id: str | None = None,
chunk_id: str | None = None,
) -> uuid.UUID:
"""Seed one memory through the store, bypassing the LLM retain pipeline.
@@ -47,7 +48,7 @@ async def _insert_memory(
tags=[],
context=None,
document_id=document_id,
chunk_id=None,
chunk_id=chunk_id,
metadata=None,
observation_scopes=None,
entities=[],
@@ -435,6 +436,187 @@ class TestDocumentUpsertObservationCleanup:
await memory.delete_bank(bank_id, request_context=request_context)
# ---------------------------------------------------------------------------
# Tests: delta retain chunk delete (regression for orphan observations, #3294)
# ---------------------------------------------------------------------------
async def _seed_chunked_document(memory: MemoryEngine, conn, bank_id: str, chunk_texts: list[str]) -> tuple[str, list]:
"""One document with ``len(chunk_texts)`` chunks, each owning one fact.
Returns the document id and, per chunk, the (chunk_id, fact_id) it owns.
"""
doc_id = str(uuid.uuid4())
await conn.execute(
"""
INSERT INTO documents (id, bank_id, original_text, content_hash, created_at, updated_at)
VALUES ($1, $2, $3, 'hash-old', NOW(), NOW())
""",
doc_id,
bank_id,
"\n\n".join(chunk_texts),
)
seeded = []
for idx, text in enumerate(chunk_texts):
chunk_id = f"{bank_id}_{doc_id}_{idx}"
await conn.execute(
"""
INSERT INTO chunks (chunk_id, document_id, bank_id, chunk_index, chunk_text, content_hash)
VALUES ($1, $2, $3, $4, $5, $6)
""",
chunk_id,
doc_id,
bank_id,
idx,
text,
f"chunk-hash-{idx}",
)
fact_id = await _insert_memory(memory, conn, bank_id, text, "experience", document_id=doc_id, chunk_id=chunk_id)
seeded.append((chunk_id, fact_id))
return doc_id, seeded
class TestDeltaChunkDeleteObservationCleanup:
"""Regression: delta retain drops facts by deleting their chunks, and that
cascade must invalidate the observations derived from them.
``handle_document_tracking`` (full replace) sweeps observations before its
delete, but the delta path never calls it it upserts the document row and
deletes the changed/removed chunks directly, cascading to memory_units. Every
delta re-ingest therefore used to leave the observations of the changed chunks
behind, valid and recallable, pointing at ids that no longer exist. Nothing
could reach them afterwards: consolidation batches are built from facts, so an
observation whose sources are all gone is never selected into a batch again.
"""
@pytest.mark.asyncio
async def test_chunk_delete_removes_observations_from_outgoing_memories(
self, memory: MemoryEngine, request_context: RequestContext
):
"""The observation of a deleted chunk's fact goes with it; its surviving
co-source is requeued for re-consolidation."""
from hindsight_api.engine.retain.chunk_storage import delete_chunks_by_ids
bank_id = f"test-delta-obs-cleanup-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
_doc_id, seeded = await _seed_chunked_document(
memory, conn, bank_id, ["Alice works at Google.", "Bob works at Microsoft."]
)
(outgoing_chunk, outgoing_fact), (_kept_chunk, kept_fact) = seeded
standalone_fact = await _insert_memory(memory, conn, bank_id, "Carol works at Netflix.")
obs_id = await _insert_observation(
memory,
conn,
bank_id,
"The team is spread across Google, Microsoft and Netflix.",
[outgoing_fact, kept_fact, standalone_fact],
)
async with pool.acquire() as conn:
async with conn.transaction():
invalidated = await delete_chunks_by_ids(conn, [outgoing_chunk], bank_id, ops=memory._backend.ops)
assert invalidated == 1, "delete_chunks_by_ids should report the observation it invalidated"
async with pool.acquire() as conn:
assert str(obs_id) not in await _get_observation_ids(conn, bank_id), (
"Observation derived from the deleted chunk's fact should have been invalidated "
"(regression #3294: the delta path cascaded the fact away and left the orphan)"
)
assert await _count_surviving(conn, bank_id, [outgoing_fact]) == 0, "The chunk's fact is gone"
# Both surviving co-sources lost an observation, so both are due for re-consolidation.
for survivor in (kept_fact, standalone_fact):
assert await _get_consolidated_at(conn, survivor, bank_id) is None, (
"Surviving co-source should be reset for re-consolidation"
)
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_chunk_delete_keeps_observations_of_unchanged_chunks(
self, memory: MemoryEngine, request_context: RequestContext
):
"""Precision: an observation sourced only from chunks that stay is untouched.
A sweep keyed on the document rather than on the deleted chunks would take
this one too and needlessly requeue the whole document for consolidation
on every small edit, which is exactly the case delta retain exists for.
"""
from hindsight_api.engine.retain.chunk_storage import delete_chunks_by_ids
bank_id = f"test-delta-obs-keep-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
_doc_id, seeded = await _seed_chunked_document(
memory, conn, bank_id, ["Alice works at Google.", "Bob works at Microsoft."]
)
(outgoing_chunk, _outgoing_fact), (_kept_chunk, kept_fact) = seeded
kept_obs = await _insert_observation(memory, conn, bank_id, "Bob is at Microsoft.", [kept_fact])
async with pool.acquire() as conn:
async with conn.transaction():
invalidated = await delete_chunks_by_ids(conn, [outgoing_chunk], bank_id, ops=memory._backend.ops)
assert invalidated == 0, "No observation of the surviving chunk should have been touched"
async with pool.acquire() as conn:
assert str(kept_obs) in await _get_observation_ids(conn, bank_id), (
"Observation of an unchanged chunk must survive the delta delete"
)
assert await _get_consolidated_at(conn, kept_fact, bank_id) is not None, (
"An untouched fact must not be requeued for consolidation"
)
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_chunk_delete_sweeps_every_deleted_chunk(self, memory: MemoryEngine, request_context: RequestContext):
"""Multiple chunks in one call: each one's observations are swept.
The reporter's bank lost the observations of a whole document at once
(25 fully orphaned from a single replace), so the sweep must cover the
entire ``chunks_to_delete`` list, not just the first entry.
"""
from hindsight_api.engine.retain.chunk_storage import delete_chunks_by_ids
bank_id = f"test-delta-obs-multi-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
_doc_id, seeded = await _seed_chunked_document(
memory,
conn,
bank_id,
["Alice works at Google.", "Bob works at Microsoft.", "Dan works at Apple."],
)
observations = [
await _insert_observation(memory, conn, bank_id, f"Observation of chunk {i}.", [fact])
for i, (_chunk, fact) in enumerate(seeded)
]
async with pool.acquire() as conn:
async with conn.transaction():
invalidated = await delete_chunks_by_ids(
conn, [chunk for chunk, _fact in seeded], bank_id, ops=memory._backend.ops
)
assert invalidated == 3
async with pool.acquire() as conn:
remaining = await _get_observation_ids(conn, bank_id)
assert [o for o in observations if str(o) in remaining] == [], (
"Every deleted chunk's observations should be swept, not just the first chunk's"
)
await memory.delete_bank(bank_id, request_context=request_context)
# ---------------------------------------------------------------------------
# Tests: delete_bank with fact_type filter
# ---------------------------------------------------------------------------
@@ -0,0 +1,111 @@
"""
Regression tests for Ollama native API extra_body handling.
The native /api/chat payload has two tiers: native top-level fields (``think``,
``keep_alive``, ...) and a nested ``options`` object (``seed``, ``top_p``,
``num_ctx``, ...). Configured ``extra_body`` must reach both, so operators can
enable thinking for gpt-oss models (``{"think": "low"}``) or tune generation
options without a code change (see #3246).
"""
import json
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from pydantic import BaseModel
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
class _SampleOutput(BaseModel):
summary: str
def _make_ollama_llm(model: str, extra_body: dict | None = None) -> OpenAICompatibleLLM:
return OpenAICompatibleLLM(
provider="ollama",
api_key="",
base_url="http://localhost:11434/v1",
model=model,
extra_body=extra_body,
)
def _mock_ollama_response(content: dict) -> httpx.Response:
body = {
"model": "test-model",
"message": {
"role": "assistant",
"content": json.dumps(content),
},
"done": True,
}
request = httpx.Request("POST", "http://localhost:11434/api/chat")
return httpx.Response(200, json=body, request=request)
async def _capture_payload(llm: OpenAICompatibleLLM) -> dict:
mock_client = AsyncMock()
mock_client.post.return_value = _mock_ollama_response({"summary": "test"})
mock_client.__aenter__.return_value = mock_client
with patch(
"hindsight_api.engine.providers.openai_compatible_llm.httpx.AsyncClient",
return_value=mock_client,
):
await llm._call_ollama_native(
messages=[{"role": "user", "content": "hello"}],
response_format=_SampleOutput,
max_completion_tokens=512,
temperature=0.1,
max_retries=0,
initial_backoff=1.0,
max_backoff=10.0,
skip_validation=True,
)
request = mock_client.post.call_args
assert request is not None
return request.kwargs["json"]
@pytest.mark.asyncio
async def test_ollama_native_think_defaults_false():
"""Thinking is disabled by default and structured-output format is included."""
payload = await _capture_payload(_make_ollama_llm("qwen3.5:2b"))
assert payload["think"] is False
assert "format" in payload
assert payload["options"]["num_predict"] == 512
assert payload["options"]["temperature"] == 0.1
@pytest.mark.asyncio
async def test_ollama_native_think_override_via_extra_body():
"""extra_body top-level field overrides the think default (gpt-oss path)."""
payload = await _capture_payload(_make_ollama_llm("gpt-oss:20b", extra_body={"think": "low"}))
assert payload["think"] == "low"
# Computed options are preserved alongside the top-level override.
assert payload["options"]["num_predict"] == 512
assert payload["options"]["temperature"] == 0.1
@pytest.mark.asyncio
async def test_ollama_native_options_merge_via_extra_body():
"""An extra_body "options" sub-dict merges into native generation options."""
payload = await _capture_payload(
_make_ollama_llm(
"qwen3.5:2b",
extra_body={"options": {"seed": 42, "temperature": 0.9}},
)
)
# New option added, and a user value wins over the computed default.
assert payload["options"]["seed"] == 42
assert payload["options"]["temperature"] == 0.9
assert payload["options"]["num_predict"] == 512
# "options" is not leaked as a top-level payload field.
assert "options" in payload
assert payload["think"] is False
@@ -19,10 +19,19 @@ without hiding what each one produces — the ``_assemble`` helper is a
mechanical join, not a re-implementation of the builder.
"""
import pytest
from hindsight_api.engine.reflect import prompts
from hindsight_api.engine.reflect.prompts import build_final_system_prompt, build_system_prompt_for_tools
BANK = {"name": "TestBank", "mission": ""}
@pytest.fixture(autouse=True)
def _freeze_current_datetime(monkeypatch):
monkeypatch.setattr(prompts, "_current_utc_datetime", lambda: "2026-08-09 14:32 UTC")
_HEADER = (
"CRITICAL: You MUST ONLY use information from retrieved tool results. "
"NEVER make up names, people, events, or entities."
@@ -30,6 +39,8 @@ _HEADER = (
_DEFAULT_ROLE = "You are a reflection agent that answers questions by reasoning over retrieved memories."
_CURRENT_DATETIME = "## Current Date and Time\nThe current date and time is 2026-08-09 14:32 UTC."
_LANGUAGE_AND_RULES = """\
## LANGUAGE RULE (default - directives take precedence)
- By default, detect the language of the user's question and respond in that SAME language.
@@ -306,6 +317,8 @@ def _assemble(
parts.append("")
parts.append(_OUTPUT_FORMAT)
parts.append("")
parts.append(_CURRENT_DATETIME)
parts.append("")
parts.append(_BANK_HEADER + trailer)
return "\n".join(parts)
@@ -560,6 +573,7 @@ _FRENCH_DIRECTIVE = {
def test_final_prompt_always_includes_language_rule():
prompt = build_final_system_prompt()
assert "The current date and time is 2026-08-09 14:32 UTC." in prompt
assert "## LANGUAGE" in prompt
assert "SAME language as the user's question" in prompt
@@ -0,0 +1,82 @@
"""Reflect sub-recalls must request entity resolution.
Canonical entity names are semantic signal the surface text may lack ("Bob"
in the text vs canonical "Robert Smith"). `recall_async` only populates each
result's `entities` field when `include_entities=True`, and it defaults to
False so both reflect retrieval tools must pass it explicitly, and the
names must survive the serialization into the tool result the agent reads.
"""
from __future__ import annotations
from dataclasses import dataclass
from unittest.mock import AsyncMock, MagicMock
import pytest
from hindsight_api.engine.reflect.tools import tool_recall, tool_search_observations
from hindsight_api.engine.response_models import MemoryFact, RecallResult
@dataclass
class _FakeRequestContext:
"""Dataclass stand-in matching the fields used by ``dataclasses.replace``."""
api_key: str | None = None
api_key_id: str | None = None
tenant_id: str | None = None
internal: bool = False
mcp_authenticated: bool = False
user_initiated: bool = False
allowed_bank_ids: list[str] | None = None
def _fact_with_entities() -> MemoryFact:
return MemoryFact(
id="123e4567-e89b-12d3-a456-426614174000",
text="Bob moved the deploy to 09:00 UTC.",
fact_type="world",
entities=["Robert Smith"],
)
def _mock_engine(results: list[MemoryFact] | None = None):
engine = MagicMock()
engine.recall_async = AsyncMock(return_value=RecallResult(results=results or [], source_facts={}))
return engine
class TestReflectRecallRequestsEntities:
@pytest.mark.asyncio
async def test_recall_passes_include_entities(self):
engine = _mock_engine()
await tool_recall(engine, "bank-1", "query", _FakeRequestContext())
assert engine.recall_async.call_args.kwargs["include_entities"] is True
@pytest.mark.asyncio
async def test_search_observations_passes_include_entities(self):
engine = _mock_engine()
await tool_search_observations(engine, "bank-1", "query", _FakeRequestContext())
assert engine.recall_async.call_args.kwargs["include_entities"] is True
@pytest.mark.asyncio
async def test_entity_names_reach_the_agent(self):
"""End-to-end through the tool's serialization (null-pruning, field
trimming): the canonical names land in the payload the agent reads."""
engine = _mock_engine(results=[_fact_with_entities()])
result = await tool_recall(engine, "bank-1", "query", _FakeRequestContext())
assert result["memories"][0]["entities"] == ["Robert Smith"]
@pytest.mark.asyncio
async def test_observation_entity_names_reach_the_agent(self):
engine = _mock_engine(results=[_fact_with_entities()])
result = await tool_search_observations(engine, "bank-1", "query", _FakeRequestContext())
assert result["observations"][0]["entities"] == ["Robert Smith"]
@@ -0,0 +1,84 @@
"""Tests for what reflect tool results carry into the agent's context.
Reflect tool results are dropped into the model's context verbatim, so every
field in them is spent context. `_drop_unread_fields` removes the retrieval
plumbing the agent never reads. The risk of removing fields is that something
downstream quietly needed one, so these tests pin BOTH directions: the heavy
fields go, and the fields the loop actually depends on stay.
"""
from __future__ import annotations
import unittest
from hindsight_api.engine.reflect.tools import _UNREAD_RESULT_FIELDS, _drop_unread_fields
from hindsight_api.engine.response_models import ChunkInfo
def _dumped_observation() -> dict:
"""A serialized result carrying every field the trim targets."""
return {
"id": "obs-1",
"text": "The deploy runs at 09:00 UTC.",
"occurred_start": "2026-08-01T00:00:00Z",
"tags": ["ops"],
"source_fact_ids": ["f1", "f2"],
"scores": {"semantic": 0.71, "reranker": 0.93, "final": 1.04},
"metadata": {"ingest_batch": "b-17"},
"entities": ["Robert Smith"],
"chunk_id": "chunk-9",
"document_id": "doc-3",
}
class DropUnreadFieldsTests(unittest.TestCase):
def test_every_unread_field_is_removed(self):
trimmed = _drop_unread_fields(_dumped_observation())
for field in _UNREAD_RESULT_FIELDS:
self.assertNotIn(field, trimmed, f"{field} should not reach the agent")
def test_the_fields_the_loop_depends_on_survive(self):
"""The direction that actually breaks things.
Citations key on ``id``; ``based_on`` persists id/text/type/context; the
expand tool takes memory_ids. Dropping any of these would silently
degrade answers rather than raise.
``entities`` survives too: it carries canonical entity *names* (not
ids), which are semantic signal the surface text may lack ("Bob" in the
text vs canonical "Robert Smith"). Reflect's recalls don't populate it
yet, but the trim must not eat the names once ``include_entities`` is
turned on.
"""
trimmed = _drop_unread_fields(_dumped_observation())
for field in ("id", "text", "occurred_start", "tags", "source_fact_ids", "entities"):
self.assertIn(field, trimmed, f"{field} is load-bearing and must survive the trim")
def test_missing_fields_are_not_an_error(self):
"""Results legitimately omit these — `_prune_nulls` runs first."""
self.assertEqual(_drop_unread_fields({"id": "obs-1"}), {"id": "obs-1"})
def test_trim_is_idempotent(self):
once = _drop_unread_fields(_dumped_observation())
self.assertEqual(_drop_unread_fields(dict(once)), once)
class ChunkEnvelopeTests(unittest.TestCase):
def test_chunk_info_carries_no_unread_fields(self):
"""Pins why ``chunks`` is left untrimmed in tool_recall.
ChunkInfo holds only chunk_text / chunk_index / truncated, so trimming
it would be a no-op and its absence is not an inconsistency. If a future
field lands here that IS plumbing, this test fails and the decision gets
revisited instead of silently going stale.
"""
overlap = set(ChunkInfo.model_fields) & set(_UNREAD_RESULT_FIELDS)
self.assertEqual(
overlap,
set(),
f"ChunkInfo gained trimmable field(s) {overlap}; tool_recall's chunks should now be trimmed too",
)
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -35,6 +35,7 @@ from hindsight_api.engine.reflect.structured_doc import (
ParagraphBlock,
Section,
StructuredDocument,
TableBlock,
make_unique_id,
parse_markdown,
render_block,
@@ -126,6 +127,33 @@ class TestRenderer:
block = CodeBlock(text="raw text")
assert render_block(block) == "```\nraw text\n```"
def test_table_one_row_per_line(self):
block = TableBlock(headers=["Layer", "Role"], rows=[["API", "HTTP"], ["Engine", "Memory"]])
assert render_block(block) == ("| Layer | Role |\n| --- | --- |\n| API | HTTP |\n| Engine | Memory |")
def test_table_escapes_pipes_in_cells(self):
block = TableBlock(headers=["col"], rows=[["a | b"]])
assert render_block(block) == "| col |\n| --- |\n| a \\| b |"
def test_table_cell_newline_collapses_to_one_line(self):
block = TableBlock(headers=["col"], rows=[["first\nsecond"]])
assert render_block(block) == "| col |\n| --- |\n| first second |"
def test_table_short_row_is_padded(self):
block = TableBlock(headers=["a", "b", "c"], rows=[["1"]])
assert render_block(block).splitlines()[-1] == "| 1 | | |"
def test_table_row_wider_than_headers_keeps_every_cell(self):
block = TableBlock(headers=["a"], rows=[["1", "2"]])
assert render_block(block) == "| a | |\n| --- | --- |\n| 1 | 2 |"
def test_table_without_headers_still_renders_rows(self):
block = TableBlock(headers=[], rows=[["1", "2"]])
assert render_block(block) == "| | |\n| --- | --- |\n| 1 | 2 |"
def test_empty_table_renders_empty(self):
assert render_block(TableBlock()) == ""
def test_section_heading_level(self):
section = Section(id="purpose", heading="Purpose", level=3, blocks=[ParagraphBlock(text="hi")])
assert render_section(section).startswith("### Purpose\n\nhi")
@@ -218,6 +246,57 @@ class TestParser:
doc = parse_markdown(markdown)
assert [s.id for s in doc.sections] == ["notes", "notes-2"]
def test_table(self):
md = "## Layers\n\n| Layer | Role |\n| --- | --- |\n| API | HTTP |\n| Engine | Memory |\n"
block = parse_markdown(md).sections[0].blocks[0]
assert isinstance(block, TableBlock)
assert block.headers == ["Layer", "Role"]
assert block.rows == [["API", "HTTP"], ["Engine", "Memory"]]
def test_table_alignment_colons_are_a_separator(self):
md = "## T\n\n| a | b |\n|:---|---:|\n| 1 | 2 |\n"
block = parse_markdown(md).sections[0].blocks[0]
assert isinstance(block, TableBlock)
assert block.headers == ["a", "b"]
assert block.rows == [["1", "2"]]
def test_table_escaped_pipe_stays_in_one_cell(self):
md = "## T\n\n| col | expr |\n| --- | --- |\n| a | x \\| y |\n"
block = parse_markdown(md).sections[0].blocks[0]
assert isinstance(block, TableBlock)
assert block.rows == [["a", "x | y"]]
def test_table_keeps_other_backslash_sequences_verbatim(self):
md = "## T\n\n| col |\n| --- |\n| C:\\path |\n"
block = parse_markdown(md).sections[0].blocks[0]
assert isinstance(block, TableBlock)
assert block.rows == [["C:\\path"]]
def test_prose_containing_a_pipe_is_not_a_table(self):
md = "## T\n\nUse a | b to pipe.\nSecond line.\n"
block = parse_markdown(md).sections[0].blocks[0]
assert isinstance(block, ParagraphBlock)
def test_pipe_rows_without_separator_are_not_a_table(self):
md = "## T\n\n| a | b |\n| 1 | 2 |\n"
block = parse_markdown(md).sections[0].blocks[0]
assert isinstance(block, ParagraphBlock)
def test_table_round_trip_via_render(self):
doc = StructuredDocument(
sections=[
Section(
id="layers",
heading="Layers",
blocks=[TableBlock(headers=["Layer", "Note"], rows=[["API", "a | b"], ["Engine", ""]])],
)
]
)
markdown = render_document(doc)
roundtripped = parse_markdown(markdown)
assert roundtripped.sections[0].blocks[0] == doc.sections[0].blocks[0]
assert render_document(roundtripped) == markdown
def test_round_trip_via_render(self):
original = _team_overview_doc()
markdown = render_document(original)
@@ -0,0 +1,246 @@
"""Regression coverage for runtime text-search reconciliation.
``ensure_text_search_extension`` issues DDL against a live database at startup,
so these tests drive it through a fake connection that records every statement.
The assertions are about *which* statements are emitted (and that they are all
re-executable), not about a real schema the shapes themselves are covered by
the migration suites.
"""
import re
from contextlib import contextmanager
from dataclasses import dataclass
import pytest
from hindsight_api import migrations
from hindsight_api._text_search import mental_models_text_document
_COUNT_TABLE = re.compile(r"SELECT COUNT\(\*\) FROM \S+\.(\w+)")
# Statements that change the schema; each one must be safely re-executable
# because replicas boot concurrently and all run this reconciliation.
_DDL_PREFIXES = ("ALTER", "CREATE", "DROP")
class _Result:
def __init__(self, *, scalar=None, row=None):
self._scalar = scalar
self._row = row
def scalar(self):
return self._scalar
def fetchone(self):
return self._row
@dataclass(frozen=True)
class _TableState:
column: str | None
index: str | None
rows: int
indexdef: str = ""
class _Connection:
def __init__(self, tables: dict[str, _TableState]):
self.tables = tables
self.statements: list[str] = []
self.table_checks: list[str] = []
self.commits = 0
def execute(self, statement, params=None):
sql = str(statement)
self.statements.append(sql)
if "information_schema.tables" in sql:
table_name = params["table_name"]
self.table_checks.append(table_name)
return _Result(scalar=table_name in self.tables)
if "information_schema.columns" in sql:
column_type = self.tables[params["table_name"]].column
return _Result(row=("USER-DEFINED", column_type) if column_type else None)
if "FROM pg_indexes" in sql:
table = self.tables[params["table_name"]]
return _Result(row=(table.index, table.indexdef) if table.index else None)
count_target = _COUNT_TABLE.search(" ".join(sql.split()))
if count_target:
return _Result(scalar=self.tables[count_target.group(1)].rows)
return _Result()
def commit(self):
self.commits += 1
@property
def ddl(self) -> list[str]:
normalized = (" ".join(s.split()) for s in self.statements)
return [s for s in normalized if s.startswith(_DDL_PREFIXES)]
class _Engine:
def __init__(self, conn):
self.conn = conn
@contextmanager
def connect(self):
yield self.conn
def _connect(monkeypatch, tables: dict[str, _TableState]) -> _Connection:
conn = _Connection(tables)
monkeypatch.setattr(migrations, "create_engine", lambda *args, **kwargs: _Engine(conn))
return conn
def _run(monkeypatch, tables: dict[str, _TableState], extension="pgroonga") -> _Connection:
conn = _connect(monkeypatch, tables)
migrations.ensure_text_search_extension("postgresql://unused", text_search_extension=extension)
return conn
def _assert_no_schema_changes(conn: _Connection) -> None:
assert conn.ddl == []
assert conn.commits == 0
def test_populated_legacy_mental_models_converts_to_pgroonga(monkeypatch):
"""The one populated transition that is allowed: the derived tsvector that
reconciliation used to skip (it checked the pre-rename `reflections` name)
is replaced by the pgroonga expression index. Nothing needs a backfill."""
conn = _run(
monkeypatch,
{
"memory_units": _TableState(column="text", index="pgroonga", rows=20),
"mental_models": _TableState(column="tsvector", index="gin", rows=5),
},
)
assert conn.table_checks == ["memory_units", "mental_models"]
assert conn.ddl == [
"DROP INDEX IF EXISTS public.idx_mental_models_text_search",
"ALTER TABLE public.mental_models DROP COLUMN IF EXISTS search_vector",
"CREATE EXTENSION IF NOT EXISTS pgroonga CASCADE",
"ALTER TABLE public.mental_models ADD COLUMN IF NOT EXISTS search_vector TEXT",
"CREATE INDEX IF NOT EXISTS idx_mental_models_text_search ON public.mental_models "
f"USING pgroonga ({mental_models_text_document()}) "
"WITH (tokenizer='TokenBigram', normalizer='NormalizerNFKC150')",
]
# memory_units already matched, so it is left alone entirely.
assert not any("memory_units" in statement for statement in conn.ddl)
assert conn.commits == 1
def test_pgroonga_state_is_idempotent(monkeypatch):
conn = _run(
monkeypatch,
{
"memory_units": _TableState(column="text", index="pgroonga", rows=20),
"mental_models": _TableState(column="text", index="pgroonga", rows=5),
},
)
assert conn.table_checks == ["memory_units", "mental_models"]
_assert_no_schema_changes(conn)
def test_populated_memory_units_backend_switch_remains_fail_closed(monkeypatch):
conn = _connect(
monkeypatch,
{
"memory_units": _TableState(column="tsvector", index="gin", rows=20),
"mental_models": _TableState(column="tsvector", index="gin", rows=5),
},
)
with pytest.raises(RuntimeError, match=r"memory_units\(20 rows\)"):
migrations.ensure_text_search_extension("postgresql://unused", text_search_extension="pgroonga")
_assert_no_schema_changes(conn)
def test_populated_unknown_mental_model_index_remains_fail_closed(monkeypatch):
"""Only the native tsvector/GIN shape is derived-only; anything else may hold
values the reconciler cannot recompute, so it must still refuse."""
conn = _connect(
monkeypatch,
{
"memory_units": _TableState(column="text", index="pgroonga", rows=20),
"mental_models": _TableState(column="tsvector", index="bm25", rows=5),
},
)
with pytest.raises(RuntimeError, match=r"mental_models\(5 rows\)"):
migrations.ensure_text_search_extension("postgresql://unused", text_search_extension="pgroonga")
_assert_no_schema_changes(conn)
def test_populated_mental_models_backfill_backend_remains_fail_closed(monkeypatch):
"""vchord's bm25vector must be tokenized per row on write, so converting a
populated table would leave every existing row unsearchable."""
conn = _connect(
monkeypatch,
{
"memory_units": _TableState(column="bm25vector", index="bm25", rows=20),
"mental_models": _TableState(column="tsvector", index="gin", rows=5),
},
)
with pytest.raises(RuntimeError, match=r"mental_models\(5 rows\)"):
migrations.ensure_text_search_extension("postgresql://unused", text_search_extension="vchord")
_assert_no_schema_changes(conn)
def test_empty_mental_models_native_reconcile_creates_generated_projection(monkeypatch):
"""No write path fills mental_models.search_vector for native, so the column
has to generate itself (see pg_search_vector_expr's native_inline=False)."""
conn = _run(
monkeypatch,
{
"memory_units": _TableState(column="tsvector", index="gin", rows=0),
"mental_models": _TableState(column="text", index="pgroonga", rows=0),
},
extension="native",
)
assert (
"ALTER TABLE public.mental_models ADD COLUMN IF NOT EXISTS search_vector tsvector "
f"GENERATED ALWAYS AS ( to_tsvector('english', {mental_models_text_document()}) ) STORED" in conn.ddl
)
assert conn.commits == 1
def test_empty_memory_units_native_reconcile_creates_plain_column(monkeypatch):
conn = _run(
monkeypatch,
{
"memory_units": _TableState(column="text", index="pgroonga", rows=0),
"mental_models": _TableState(column="tsvector", index="gin", rows=0),
},
extension="native",
)
assert "ALTER TABLE public.memory_units ADD COLUMN IF NOT EXISTS search_vector tsvector" in conn.ddl
@pytest.mark.parametrize("extension", ["native", "vchord", "pg_textsearch", "pgroonga", "pg_search"])
def test_reconcile_ddl_is_re_executable(monkeypatch, extension):
"""Replicas boot concurrently during a rolling restart and each runs this
reconciliation, so the loser of the race must not crash on DDL the winner
already committed."""
conn = _run(
monkeypatch,
{
"memory_units": _TableState(column=None, index=None, rows=0),
"mental_models": _TableState(column=None, index=None, rows=0),
},
extension=extension,
)
assert conn.ddl, "expected reconciliation to emit DDL"
for statement in conn.ddl:
assert re.match(
r"(CREATE (INDEX|EXTENSION) IF NOT EXISTS|DROP INDEX IF EXISTS"
r"|ALTER TABLE \S+ (ADD|DROP) COLUMN IF (NOT )?EXISTS)",
statement,
), f"non re-executable DDL: {statement}"
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-api"
version = "0.8.6"
version = "0.9.0"
description = "Hindsight: Agent Memory That Works Like Human Memory"
license = "MIT"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim[all]==0.8.6",
"hindsight-api-slim[all]==0.9.0",
]
[tool.uv.sources]
+8
View File
@@ -64,7 +64,15 @@ llm_request_stats = "UI-only endpoint for the control plane LLM Requests stats c
# Document transfer (export/import) is an admin/ops operation used from the API
# and the control plane, not the end-user Rust CLI.
export_documents = "Admin/ops operation, used via the API and control plane, not the end-user CLI"
export_documents_sync_removed = "Removed sync export endpoint kept as a 410 stub; nothing should call it"
import_documents = "Admin/ops operation, used via the API and control plane, not the end-user CLI"
download_file = "Serves async export archives; fetched via the API/control plane, not the end-user CLI"
# Kubernetes probe endpoints. `hindsight health` already calls /health, which is
# the readiness check; /health/ready is its alias and /health/live is a DB-free
# liveness signal meant for kubelet, not for a human at a terminal.
get_liveness = "Liveness probe for orchestrators; `hindsight health` covers the human-facing check"
get_readiness = "Alias of /health, which `hindsight health` already calls"
# ---------------------------------------------------------------------------
# Per-operation parameter skips
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.8.6"
version = "0.9.0"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
+225 -34
View File
@@ -7,13 +7,16 @@ info:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
title: Hindsight HTTP API
version: 0.8.6
version: 0.9.0
servers:
- url: /
paths:
/health:
get:
description: Checks the health of the API and database connection
description: "Readiness check: verifies the API can reach the database. Alias\
\ of /health/ready. Use /health/live for liveness probes — this one fails\
\ whenever the database is unreachable, which must gate traffic, not restart\
\ the process."
operationId: health_endpoint_health_get
responses:
"200":
@@ -24,6 +27,36 @@ paths:
summary: Health check endpoint
tags:
- Monitoring
/health/ready:
get:
description: "Returns 200 when the API can serve traffic (database reachable),\
\ 503 otherwise. Identical to /health, which stays supported as its alias."
operationId: get_readiness
responses:
"200":
content:
application/json:
schema: {}
description: Successful Response
summary: Readiness probe
tags:
- Monitoring
/health/live:
get:
description: "Returns 200 whenever the process can serve a request. Performs\
\ no database access, so a slow or unreachable database never restarts the\
\ pod. Point livenessProbe here and readinessProbe at /health."
operationId: get_liveness
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/LivenessResponse'
description: Successful Response
summary: Liveness probe
tags:
- Monitoring
/version:
get:
description: Returns API version information and enabled feature flags. Use
@@ -3233,13 +3266,14 @@ paths:
- Bank Templates
/v1/default/banks/{bank_id}/document-transfer:
get:
description: "Export documents (extracted facts, entity names, causal links,\
\ chunks) from a bank as a transfer ZIP archive. Embeddings and database ids\
\ are not included — importing re-embeds with the target bank's model and\
\ re-resolves entities. Consolidated observations are excluded unless include_observations=true.\
\ Pass document_id query params to export specific documents, or omit to export\
\ the whole bank."
operationId: export_documents
deprecated: true
description: "**Removed.** The synchronous whole-bank export loaded the entire\
\ bank into memory and held a database connection for the full request, which\
\ could exhaust memory and take down the shared API on large banks. Use the\
\ asynchronous POST /v1/default/banks/{bank_id}/document-transfer/export instead:\
\ it returns an operation_id, runs the export in the background, and exposes\
\ a download URL on completion."
operationId: export_documents_sync_removed
parameters:
- explode: false
in: path
@@ -3249,28 +3283,6 @@ paths:
title: Bank Id
type: string
style: simple
- description: Document id(s) to export; omit for all
explode: true
in: query
name: document_id
required: false
schema:
items:
type: string
nullable: true
type: array
style: form
- description: Also export consolidated observations (restored on import)
explode: true
in: query
name: include_observations
required: false
schema:
default: false
description: Also export consolidated observations (restored on import)
title: Include Observations
type: boolean
style: form
- explode: false
in: header
name: authorization
@@ -3284,15 +3296,14 @@ paths:
content:
application/json:
schema: {}
application/zip: {}
description: Transfer archive
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Export documents
summary: Export documents (removed — use POST .../document-transfer/export)
tags:
- Document Transfer
post:
@@ -3353,6 +3364,117 @@ paths:
summary: Import documents (async)
tags:
- Document Transfer
/v1/default/banks/{bank_id}/document-transfer/export:
post:
description: "Submit an async export of a bank's documents (extracted facts,\
\ entity names, causal links, chunks) as a transfer ZIP archive. Embeddings\
\ and database ids are not included — importing re-embeds with the target\
\ bank's model and re-resolves entities. Runs as a background operation to\
\ avoid pinning the API on large banks. Returns an operation_id; poll GET\
\ /v1/default/banks/{bank_id}/operations/{operation_id}. On completion the\
\ operation's result_metadata carries download_url (fetch the ZIP from GET\
\ /v1/default/files/download/{key}), storage_key, byte_size, and filename.\
\ Pass document_id query params to export specific documents, or omit to export\
\ the whole bank; include_observations=true also carries consolidated observations\
\ (whole-bank export only)."
operationId: export_documents
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- description: Document id(s) to export; omit for all
explode: true
in: query
name: document_id
required: false
schema:
items:
type: string
nullable: true
type: array
style: form
- description: Also export consolidated observations (restored on import; whole-bank
only)
explode: true
in: query
name: include_observations
required: false
schema:
default: false
description: Also export consolidated observations (restored on import;
whole-bank only)
title: Include Observations
type: boolean
style: form
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"202":
content:
application/json:
schema:
$ref: '#/components/schemas/DocumentExportSubmitResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Export documents (async)
tags:
- Document Transfer
/v1/default/files/download/{key}:
get:
description: Stream a file previously written to file storage — currently the
transfer ZIP produced by an async document export. The key comes from the
export operation's result_metadata (storage_key / download_url). Access is
authorized against the bank the key belongs to.
operationId: download_file
parameters:
- explode: false
in: path
name: key
required: true
schema:
title: Key
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema: {}
application/zip: {}
description: Stored file
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Download a stored file (async export archive)
tags:
- Document Transfer
/v1/bank-template-schema:
get:
description: Returns the JSON Schema for the bank template manifest format.
@@ -5145,6 +5267,27 @@ components:
store_document_text:
nullable: true
type: boolean
enable_auto_consolidation:
nullable: true
type: boolean
consolidation_max_memories_per_round:
nullable: true
type: integer
consolidation_llm_parallelism:
nullable: true
type: integer
recall_include_chunks:
nullable: true
type: boolean
recall_max_tokens:
nullable: true
type: integer
recall_chunks_max_tokens:
nullable: true
type: integer
memory_defense:
additionalProperties: {}
nullable: true
title: BankTemplateConfig
BankTemplateDirective:
description: |-
@@ -6007,6 +6150,28 @@ components:
- literalism
- skepticism
title: DispositionTraits
DocumentExportSubmitResponse:
description: |-
Response for the async document-export endpoint (202).
The export runs in the background; poll the operations endpoint for status.
On completion the operation's ``result_metadata`` carries ``download_url``
(fetch the ZIP from GET /v1/default/files/download/{key}), ``storage_key``,
``byte_size``, and ``filename``.
example:
operation_id: operation_id
status: pending
properties:
operation_id:
title: Operation Id
type: string
status:
default: pending
title: Status
type: string
required:
- operation_id
title: DocumentExportSubmitResponse
DocumentImportSubmitResponse:
description: |-
Response for the async document-import endpoint (202).
@@ -7484,6 +7649,32 @@ components:
- offset
- total
title: ListTagsResponse
LivenessResponse:
description: Payload for the API's DB-free liveness probe.
example:
status: alive
uptime_seconds: 812.4
version: 0.4.0
properties:
status:
description: Always "alive" — reaching this handler is the check
enum:
- alive
title: Status
type: string
version:
description: Hindsight version this process is running
title: Version
type: string
uptime_seconds:
description: Seconds since the process started
title: Uptime Seconds
type: number
required:
- status
- uptime_seconds
- version
title: LivenessResponse
LlmOperationHealth:
description: |-
LLM connectivity status for a single operation. Status only — no provider/model/
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.6
API version: 0.9.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.6
API version: 0.9.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.6
API version: 0.9.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.8.6
API version: 0.9.0
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.

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