Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 87856fcc7e fix(opencode): default-export the Plugin function directly
OpenCode's plugin loader iterates Object.entries(mod) and invokes every
export as a Plugin factory `(input) => Promise<Hooks>`, deduping by
identity. Our prior default export was a PluginModule object
(`{ id, server }`), which opencode tried to call as a function and
crashed with `fn3 is not a function. (In 'fn3(input)', 'fn3' is an
instance of Object)` at load time.

Default-export the HindsightPlugin function itself so both default and
named `HindsightPlugin` exports point to the same reference (dedupe
suppresses a second call). Update the default-export smoke test to
assert this invariant.

Verified end-to-end against opencode 1.1.49 with the built dist — the
plugin now initializes, registers tools/hooks, and processes session
events without error.
2026-04-14 15:37:55 +02:00
Nicolò Boschi eefa449d54 docs(opencode): drop misleading npm install step, document Hindsight Cloud
OpenCode auto-installs plugins listed in the "plugin" array at startup via
Bun; the prior instructions to `npm install` the package were misleading.
Also add a dedicated Hindsight Cloud section with api.hindsight.vectorize.io
and token guidance.
2026-04-14 15:31:52 +02:00
Nicolò Boschi 7d5d5b2781 release(opencode): v0.1.3 2026-04-14 15:17:36 +02:00
AldousandAldous the Orchestrator b79ab2b752 feat(openclaw): merge inline retain tags with defaults (#948)
* feat(openclaw): close remaining retain parity gaps

* docs(openclaw): preserve transcript format for retain parity patch

* refactor(openclaw): drop unused retain prefix config

* fix(openclaw): keep retain tag normalization narrow

* feat(openclaw): merge inline retain tags with defaults

---------

Co-authored-by: Aldous the Orchestrator <[email protected]>
2026-04-14 14:17:50 +02:00
Mr. Khachaturov cf9918891b docs(configuration): document HINDSIGHT_API_RETAIN_CHUNK_BATCH_SIZE (#1045)
The retain_chunk_batch_size hierarchical config field and its
ENV_RETAIN_CHUNK_BATCH_SIZE loader have existed in HindsightConfig
since the retain streaming batch landed, but the Retain section of
the configuration reference never got a row for them — users who
want to cap chunk-batch size on large document ingestion had to
discover the env var by grepping the source.

Add a row to the Retain table next to the other chunk/batch knobs,
with the same format as surrounding entries and an explicit note
that the field is configurable per bank via the bank config API.
2026-04-14 14:06:43 +02:00
Nicolò Boschi 9372462e13 fix(clients): set identifying User-Agent on all HTTP requests (#1041) (#1052)
Cloudflare (and other proxies with UA-based bot filtering) block the
default "Python-urllib/X.Y" and "reqwest/..." UA strings with error 1010,
causing all retain/recall traffic to silently fail against self-hosted
deployments.

Generated-client wrappers now send "hindsight-client-<lang>/<version>"
by default and expose a user_agent/userAgent override so integrations
can identify themselves. Each integration passes its own UA
("hindsight-<integration>/<version>") at client construction.

Integrations using raw urllib/fetch (claude-code, codex, openclaw,
paperclip) set the header directly in their HTTP layer — this fixes
the reported Cloudflare 1010 issue for the claude-code plugin.
2026-04-14 13:58:44 +02:00
Nicolò Boschi f2fc8f9f26 feat(api): add recall controls to mental model trigger (#1048)
* feat(api): add recall controls to mental model trigger

Internal recall during mental model refresh used to hardcode
include_chunks=True with fixed token budgets, wasting prompt budget on
chunks that some refreshes don't need.

Adds three knobs exposed both as hierarchical config (env -> tenant ->
bank) and as per-mental-model overrides on the trigger JSONB field:

- recall_include_chunks / trigger.include_chunks
- recall_max_tokens / trigger.recall_max_tokens
- recall_chunks_max_tokens / trigger.recall_chunks_max_tokens

Trigger value (when set) wins over bank/global config. Both refresh
paths (task handler and synchronous refresh_mental_model) forward the
overrides into reflect_async.

* feat(control-plane): expose recall trigger fields in mental model dialogs

Adds form fields under the Options tab for the three new trigger
overrides (include_chunks, recall_max_tokens, recall_chunks_max_tokens)
in both the create and update mental model dialogs. Empty/Default means
inherit the bank/global config.

* fix(control-plane): cap mental model dialog height and add scroll

* style(control-plane): theme scrollbars to match app surface

* refactor(control-plane): group mental model options into Refresh/Tags/Recall sections

* refactor(control-plane): move Fact Types into Recall, add Other Mental Models section

* fix(cli): pass new recall trigger fields in MentalModelTriggerInput

* chore: regenerate hindsight-docs skill openapi/configuration

* test(hierarchical-config): bump configurable field count for new recall fields
2026-04-14 13:27:16 +02:00
Nicolò Boschi 6a80ecbf65 docs: reframe observations as evidence-grounded consolidated knowledge (#1051)
* docs: reframe observations as evidence-grounded consolidated knowledge

The previous framing leaned on "synthesis" and "patterns", which reads as
LLM summarization and undersells what observations actually are: deduplicated
beliefs grounded in specific source memories (with quotes), refined — not
overwritten — when new evidence arrives, and carrying a computed freshness
trend (stable / strengthening / weakening / stale).

* docs: regenerate hindsight-docs skill references
2026-04-14 12:30:04 +02:00
Nicolò Boschi 870bf4a3d1 feat(operations): expose task_payload and document_ids on async ops (#1049)
* feat(operations): expose task_payload and document_ids on async ops

Add a "Load raw" affordance to the operations dialog so users can
inspect which document(s) an async operation was processing. Motivated
by pending/failed retain ops where there was previously no way to tell
which content was in flight.

- API: `GET /v1/default/banks/{bank_id}/operations/{operation_id}` now
  accepts `?include_payload=true` and returns `task_payload` (the raw
  submission params). Off by default since payloads can be large.
- Retain: replaces the singular `generated_document_id` in
  `result_metadata` with a `document_ids: list[str]` that captures
  every effective doc id (user-provided or generated), via an atomic,
  idempotent JSONB set-append. Multi-doc retains and user-supplied ids
  are now visible from the operation row.
- Control plane: dialog shows `result_metadata` as JSON (always) and
  a "Load raw" button that fetches the payload on demand; handles
  parent ops (payload lives on children) with a clear message.
- Regenerate OpenAPI spec and Python/TS/Rust/Go clients.
- Add tests covering user-supplied/generated/shared document_ids and
  the include_payload query param.

* chore: regenerate hindsight-docs skill openapi.json

* fix(cli): pass new include_payload arg to get_operation_status
2026-04-14 11:57:52 +02:00
Mr. Khachaturov 099f4c925a fix(bank-template): align BankTemplateConfig with _CONFIGURABLE_FIELDS (#1044)
BankTemplateConfig declared 12 hierarchical config fields, but
HindsightConfig._CONFIGURABLE_FIELDS — the allowlist the engine uses
to decide what can be overridden per-bank — contains 22. Ten fields
existed in HindsightConfig and config_resolver.update_bank_config()
accepted them, but the template import path at
POST /v1/default/banks/{id}/import couldn't deliver them: the
manifest handler resolves overrides via BankTemplateConfig.get_config_updates(),
which is a model_dump() filter, so any field not declared on the model
is silently dropped before reaching update_bank_config().

Expose the ten missing fields on BankTemplateConfig so they flow
through get_config_updates() and reach update_bank_config() unchanged:
retain_default_strategy, retain_strategies, retain_chunk_batch_size,
mcp_enabled_tools, consolidation_llm_batch_size,
consolidation_source_facts_max_tokens,
consolidation_source_facts_max_tokens_per_observation,
max_observations_per_scope, reflect_source_facts_max_tokens,
llm_gemini_safety_settings.

No engine changes. No new validation. config_resolver.update_bank_config()
already validates these fields correctly through _CONFIGURABLE_FIELDS;
the template manifest schema was the only thing blocking the path.

Adds a parametrized integration test that POSTs each new field through
/v1/default/banks/{id}/import and asserts the applied value round-trips
via GET /v1/default/banks/{id}/config under the "overrides" slot, matching
the shape test_import_applies_config already uses at
tests/test_bank_templates.py.
2026-04-14 11:54:34 +02:00
Nicolò Boschi e08faadc17 feat(worker): log [PENDING_BREAKDOWN] bucketing pending rows by claim filter (#1050)
Production incident: a 'pending' retain sat in the queue for hours while
workers had free slots. WORKER_STATS only reports the global pending count,
so there was no way to tell whether the rows were claimable-but-not-claimed
(real bug) vs filtered out by the claim WHERE clause (data state — orphaned
batch_retain parents with task_payload IS NULL, retry backoff, or worker_id
already stamped).

Add one extra periodic line, only when global_pending > 0, that buckets
pending rows per operation_type by the predicates the claim query filters
on. ``claimable`` is the residual that should be picked up next poll; if
``claimable > 0`` while workers report free slots, the bug is somewhere
else (lock contention, tenant discovery) and that line narrows the search.

[PENDING_BREAKDOWN] batch_retain: total=1 claimable=0 payload_null=1 ...
                  | retain: total=3 claimable=1 payload_null=0 retry_blocked=1 assigned=1
                  | consolidation: total=26 claimable=26 payload_null=0 ...

Implementation reuses the existing per-schema loop in _log_progress_if_due,
adding one GROUP BY query per schema. Buckets are aggregated across schemas
before rendering.
2026-04-14 11:36:38 +02:00
Nicolò Boschi dbd1d1a743 fix(retain): prevent IndexError on embeddings/facts length mismatch (#1037) (#1047)
`generate_embeddings_batch` now raises if the backend returns a different
number of vectors than input texts, instead of letting `zip()` silently
drop facts and surface later as `IndexError` in `_map_results_to_contents`.

`_map_results_to_contents` is also reworked to iterate `processed_facts`
(which is 1:1 with `unit_ids` by construction) and validates the lengths
match, providing defense-in-depth against any future drift.
2026-04-14 11:14:07 +02:00
Ben c084765950 blog: Update OpenClaw post for v0.6.0/v0.6.2 (#1038)
* blog: update OpenClaw post to reflect v0.6.0/v0.6.2 plugin changes
2026-04-13 15:39:00 -04:00
Nicolò Boschi d6ad53986a feat: add hindsight-architect skill (#1035) 2026-04-13 18:13:44 +02:00
DK09876andClaude Opus 4.6 6076354a9c fix(opencode): fix message parsing, shared state, and post-compaction retain (#1034)
Three bugs fixed:
1. msg.role → msg.info.role: OpenCode SDK wraps role inside info, so all
   messages were silently filtered out, breaking retain and recall (#941)
2. Move PluginState to module level so it persists across sessions instead
   of being recreated per plugin instantiation
3. Reset lastRetainedTurn after compaction so idle-retain resumes when the
   message list shrinks

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-04-13 18:05:25 +02:00
Nicolò Boschi 9f9c3a1b40 release(opencode): v0.1.2 2026-04-13 16:13:54 +02:00
Nicolò Boschi 8ba862b026 release(openclaw): v0.6.2 2026-04-13 16:08:28 +02:00
apnea fd87de9c15 fix(opencode-plugin): correct session.messages response shape and update tests (#993) 2026-04-13 16:02:04 +02:00
Nicolò Boschi adc85129ba feat(openclaw): retain as Anthropic-shaped JSON with tool_use/tool_result blocks (#1031)
* feat(openclaw): retain conversation as JSON by default

Default retention payload now mirrors the Claude Code integration: a
JSON-stringified array of {role, content} message objects, instead of the
legacy `[role: x] ... [x:end]` text markers. Structured JSON makes
downstream consumers (recall reranking, control-plane document viewer,
external pipelines) much easier to parse and stops fact extraction from
chasing the marker syntax as if it were content.

Add `retainFormat: "json" | "text"` plugin config (default `"json"`) so
operators can roll back to the legacy text shape if a custom downstream
pipeline depends on it.

* feat(openclaw): retain tool_use and tool_result blocks by default

Extends the JSON retain format so each message's content is an
Anthropic-shaped block array — text, tool_use, tool_result — instead of
a flat string. The agent's tool calls (with full inputs) and tool
results are now preserved in memory, matching what the Claude Code
integration stores and giving downstream fact extraction / recall
rerank a much richer signal.

- New `retainToolCalls` config (default true). Set false to keep
  flat-string content per message.
- Operational Hindsight MCP tools (recall/retain/search/CRUD) are
  filtered out to prevent feedback loops.
- Tool result content truncated at 2000 chars.
- OpenClaw's native shape (toolCall blocks inside assistant messages,
  separate role=toolResult messages) is normalized to Anthropic's shape
  on the way out: tool_use stays on assistant, tool_result becomes a
  synthesized user message containing just the tool_result block.
- `thinking` blocks are dropped.
2026-04-13 15:56:11 +02:00
Voscko 2ff805d6e9 fix(openclaw): stabilize session identity and skip operational turns (#987)
* fix(openclaw): stabilize session identity and skip operational turns

* test(openclaw): validate dispatch identity guardrails

* fix(openclaw): address review feedback on identity guardrails
2026-04-13 15:55:29 +02:00
Nicolò Boschi 8125a0d758 docs: add 0.5.1 changelog entry and release blog post (#1032)
- Generated 0.5.1 section in changelog via scripts/dev/generate-changelog.sh
- Added "What's new in Hindsight 0.5.1" blog post covering CLI coverage,
  Cloudflare OAuth proxy, default bank template, SiliconFlow reranker,
  hindsight-all daemon lifecycle package, and reliability fixes
2026-04-13 15:54:06 +02:00
Ben e1e137b027 blog: How I Built Multi-User AI Memory into a Financial Product from Day One (#1030)
* blog: Add Ming Fang fintech customer story — multi-user AI memory from day one
2026-04-13 09:52:30 -04:00
r266-tech 6b5aa3afe8 fix(embedded): add timeout to _cleanup lock acquisition (#1023)
* fix(embedded): add timeout to _cleanup lock acquisition (#1022)

_cleanup() acquires self._lock with a bare 'with' statement. When another
thread holds the lock (e.g. _ensure_started mid-operation), Ctrl+C causes
the shutdown path to hang indefinitely.

Replace with self._lock.acquire(timeout=5.0) so cleanup completes within
5 seconds even when the lock is contended. If timeout expires, proceed
with best-effort cleanup and log a warning.

Also wrap self._client.close() in try/except since the client may be in
an inconsistent state during interrupted shutdown.

Closes #1022

* test(embedded): add unit test for _cleanup lock timeout behavior

* fix(embedded): rework — skip shared-state teardown on lock timeout

Address Codex review findings:
- On timeout, only set _closed flag (prevents new ops) and return.
  Do NOT mutate shared state without the lock — the daemon's idle
  timeout handles cleanup on its own.
- Log client.close() exceptions at DEBUG level instead of swallowing.
2026-04-13 15:47:50 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> e28b8c00f6 chore(deps): bump softprops/action-gh-release from 2 to 3 (#1024)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 15:42:18 +02:00
Nicolò Boschi 1be5ff33b0 fix(openclaw): register agent hooks on every plugin entry invocation (#1029)
OpenClaw calls the plugin entry multiple times per process (CLI, gateway,
lazy reloads), each with a fresh api bound to its own plugin registry. A
module-level `hooksRegistered` flag let the first call win and left later
registries with zero hindsight hooks — so auto-recall/auto-retain silently
stopped firing on live agent turns in 0.6.0/0.6.1.

Also document in CLAUDE.md that changelogs never carry "Unreleased"
sections; the release script writes entries at cut time.
2026-04-13 15:38:00 +02:00
Nicolò Boschi aeb0c8b553 Release v0.5.1
- Update version to 0.5.1 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- hindsight-all npm wrapper: hindsight-all-npm
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
- Sync documentation to version-0.5
2026-04-13 12:11:04 +02:00
Nicolò Boschi d0b2ab9ad2 feat(reranker): add SiliconFlow provider; share Cohere-compatible HTTP client (#1019)
* feat(reranker): add SiliconFlow provider and share Cohere-compatible HTTP client

Closes #859.

Adds a `siliconflow` reranker provider for SiliconFlow's Cohere-compatible
`/rerank` endpoint, and refactors ZeroEntropy plus the Cohere custom-base_url
code path onto a shared `_CohereCompatibleRerankClient`. Setting
`HINDSIGHT_API_RERANKER_COHERE_BASE_URL` now routes the `cohere` provider
through the same HTTP client, making it a generic entry point for any
Cohere-compatible rerank host (Azure AI Foundry, Jina, Voyage, self-hosted
BGE, ...).

* fixup: update cohere tests for shared HTTP client + regen docs skill + ruff format
2026-04-13 12:04:12 +02:00
Nicolò Boschi 93562bfaaf release(openclaw): v0.6.1 2026-04-13 12:02:05 +02:00
Nicolò Boschi 9679d8139d fix(openclaw): setup wizard now asks for token value, not env var name (#1021)
User feedback from the 0.6.0 wizard: the prompt "Environment variable
holding your Hindsight Cloud API token" is confusing. Users paste the
raw token (or worse, the whole `NAME=value` pair), get an
UPPER_SNAKE_CASE validation error, and have no idea the wizard expected
a name instead of the value.

Rework: the interactive wizard now asks for the token / API key VALUE
via `p.password()` (masked input) and stores it inline as a plaintext
string in openclaw.json. The outro note tells users where the secret
was stored and shows the one-liner to switch to a SecretRef later.

For CI / production where a SecretRef is preferred, the existing
`--token-env` and `--api-key-env` non-interactive flags continue to
work. Also added their direct-value counterparts:

  --token <value>     stores inline in openclaw.json
  --token-env <VAR>   stores as SecretRef

  --api-key <value>   stores inline in openclaw.json
  --api-key-env <VAR> stores as SecretRef

`--token` / `--token-env` and `--api-key` / `--api-key-env` are
mutually exclusive within a mode. For api mode, any combination with
`--no-token` is also rejected.

The plugin manifest marks `llmApiKey` and `hindsightApiToken` as
sensitive, so `openclaw config get` continues to redact their values
regardless of storage shape.

Tests: 142 unit tests (up from 127 pre-change) cover both direct-value
and SecretRef paths across all three modes, plus the new mutual-
exclusivity errors. Smoke test exercises 7 setup variants (was 4) and
5 negative tests (was 3); all pass end-to-end against a real openclaw
install.
2026-04-13 11:53:00 +02:00
Nicolò Boschi ab7feb144b feat(worker): diagnostic logging for stuck/slow async tasks (#1017)
* feat(worker): diagnostic logging for stuck/slow async tasks

Surface what each in-flight worker task is doing so users can diagnose
stalls (issue #1001) and runaway LLM retry loops (#996) from logs alone,
without killing tasks and losing the forensic trail.

Adds four new periodic log lines (every 30s):

* [WORKER_STATS] now includes asyncpg pool stats (idle/in_use/waiters)
  and process RSS — pool exhaustion and unbounded memory growth are
  invisible without these.
* [WORKER_TASK] one line per in-flight task with op_id, type, bank,
  age, current stage, and stage age. Sorted oldest-first; tasks past
  5 min get a [STUCK?] prefix.
* [STUCK_STACK] async stack trace dumped once per doubling threshold
  (5/10/20/40 min...) so stuck tasks self-document without flooding.
* [DB_WAITS] pg_stat_activity snapshot of any non-idle Hindsight
  session waiting on a lock — catches the retain-pipeline deadlock
  case where the coroutine looks fine but is blocked on a Postgres lock.

Stage breadcrumbs are wired via a contextvar (worker/stage.py) at:

* memory_engine.execute_task — task.{type}
* retain/orchestrator phases — retain.phase1/2/3, retain.extract_and_embed
* llm_wrapper.call/call_with_tools — llm.{provider}.{scope}[+structured|+tools]
* per-attempt updates in openai_compatible (incl. _call_ollama_native),
  litellm, and gemini retry loops — llm.{provider}.{scope}.attempt=N/M

The attempt counter makes JSON-schema retry loops on small models
visible by stage name + stage age, instead of needing to bump log
level and grep for WARN lines.

set_stage is a no-op outside a worker context, so engine code is safe
to call from sync HTTP requests, tests, and the CLI without setup.

* fix(test-api): repair regressions from main merges

Three independent regressions surfaced in test-api after recent merges to
main; fix all of them so this PR's CI can pass.

1. apply_combined_scoring overwrote single-result scores

   #957 added passthrough-reranker detection via `len(ce_scores) <= 1`,
   which also triggers for n=1 candidate cases — corrupting any
   single-result rerank by replacing the real CE score with a rank-based
   value. It also misfired when multiple legitimate results happened to
   tie on score (common in tests with synthetic data).

   Replace the heuristic with an explicit `is_passthrough_reranker`
   parameter, set by the caller based on `cross_encoder.provider_name`.
   Fixes 13 tests across test_combined_scoring and test_reranking_proof_count.

2. tool_search_observations breaks when request_context is a MagicMock

   #972 added `replace(request_context, internal=True)` inside
   tool_search_observations to avoid double-billing internal recall calls.
   The existing test suite passes a MagicMock as request_context, which
   `dataclasses.replace` rejects.

   Update the test fixture to pass a real RequestContext dataclass.
   Fixes 4 tests in test_reflect_source_facts_config.

3. recall_id collisions cause "Operation already exists"

   recall_id was `f"{bank_id[:8]}-{int(time.time() * 1000) % 100000}"` —
   two recalls on the same bank within the same millisecond collide,
   raising ValueError from budgeted_operation. This presented as flaky
   "Operation recall-... already exists" failures in test_consolidation
   and test_consolidation_failure_recovery.

   Append a uuid suffix so recall_id is guaranteed unique.

* fix: repair main-branch CI regressions blocking this PR

* test-embed: 3 tests in test_profile_daemon_config.py patched
  manager.is_running to True, but #1016 added pre-Popen is_running
  checks in _start_daemon and _start_daemon_locked that short-circuit
  on True, so Popen was never called and the env was never captured.
  Make is_running return False before Popen and True after via a
  popen_called flag, so both pre-Popen guards proceed and the
  post-Popen readiness loop breaks immediately. Patch time.sleep too
  to skip the 2s stability wait.

* test-openclaw-integration: package.json required hindsight-all@^0.1.0
  but the workspace ships 0.5.0, so npm ci refused. Bump the constraint
  to ^0.5.0 and regenerate package-lock.json.

* verify-generated-files: regenerate skills/hindsight-docs/references
  for mental-models.md and cli.md (drift on main, untouched by this PR).
2026-04-13 11:37:52 +02:00
Nicolò Boschi 0c4b79b6d3 chore(ci): guard against workspace-resolved deps in integration lockfiles (#1020)
The openclaw 0.6.0 release workflow failed at `npm run build` because
`hindsight-integrations/openclaw/package-lock.json` had
`@vectorize-io/hindsight-client` resolved as a workspace symlink
(`link: true`) instead of a registry URL. npm had silently preferred the
workspace over the declared registry version when `npm install` was
originally run from the monorepo root, even though openclaw isn't in
the root `workspaces` array. The release runner has no pre-built
workspace `dist/`, so tsc couldn't find the types and the publish never
happened. (The test CI job masked this because it explicitly pre-builds
workspace deps before `npm ci`.)

Add two guards so it can't recur:

1. `scripts/check-integration-lockfiles.sh` — scans every
   `hindsight-integrations/*/package-lock.json` and fails if any dep's
   `resolved` URL is empty, a `file:` URL, a relative path, or the entry
   is a `link: true` workspace symlink. Prints the exact fix (regenerate
   the lockfile from inside the integration directory, not the monorepo
   root).

2. `check-integration-lockfiles` job in `.github/workflows/test.yml` —
   runs the script on every PR that touches an integration lockfile or
   package.json. Gated on the new `integrations-lockfiles` detect-changes
   output. Added to `report-pr-status` needs list.

3. Inline `Check integration lockfile` step in `release-integration.yml`
   for the TypeScript branch — belt + suspenders in case a bad lockfile
   ever slips past PR gating.

Verified: regression-tested the script against the broken pre-release
lockfile from commit da21e072 and it correctly identifies
`node_modules/@vectorize-io/hindsight-client: (link=true — workspace
symlink)` and exits non-zero. On the current tree (post-fix) all 7
integration lockfiles pass.
2026-04-13 11:35:46 +02:00
Nicolò Boschi e9270fd312 fix(openclaw): resolve hindsight-* deps from the npm registry, not workspace
The release-integration.yml workflow failed at `tsc` with
  Cannot find module '@vectorize-io/hindsight-client' or its corresponding
  type declarations.

Root cause: the openclaw integration's package-lock.json had
@vectorize-io/hindsight-client resolved to ../../hindsight-clients/typescript
— the monorepo workspace path. That happened because an earlier
`npm install` was run from the monorepo root, where npm preferred the
workspace over the registry even though openclaw isn't itself listed in
the root workspaces array. Locally the build worked because the
workspace directory exists; in CI the workspace's `dist/` is gitignored
and not built before the release workflow's `npm ci`, so tsc couldn't
resolve the types.

Regenerated the lockfile from within the openclaw directory so npm
resolves @vectorize-io/hindsight-client (^0.5.0) and
@vectorize-io/hindsight-all (^0.1.0) directly from the npm registry. The
lockfile's `resolved` URLs now point at registry.npmjs.org.
2026-04-13 10:48:53 +02:00
Nicolò Boschi da21e0727c release(openclaw): v0.6.0 2026-04-13 10:43:57 +02:00
Nicolò Boschi d4b8b3544b fix(openclaw): ignore ctx.channelId when it is a provider name (#854) (#1018)
Some OpenClaw hook contexts populate `ctx.channelId` with the provider
name (e.g. "discord") instead of the actual channel ID, which short-
circuited the sessionKey fallback in `deriveBankId` and collapsed all
Discord channel memories into a single `main::discord` bank.

Add a `sanitizeChannelId` helper that treats `ctx.channelId` as missing
when it equals the provider or matches a known provider token, so the
parsed sessionKey channel is used instead. Apply it to both
`deriveBankId` and `buildRetainRequest` so `channel_id` metadata and
thread extraction also benefit.
2026-04-13 10:33:17 +02:00
Nicolò Boschi 873223964b feat(openclaw): interactive setup wizard with Cloud / API / Embedded modes (#1014)
* feat(openclaw): interactive setup wizard with Cloud / API / Embedded modes

Ship a new `hindsight-openclaw-setup` bin that walks users through picking a
mode and writes the corresponding plugin config into openclaw.json:

- Cloud — managed Hindsight (default URL + token SecretRef)
- External API — user's own running Hindsight (URL + optional token SecretRef)
- Embedded daemon — local hindsight-all daemon (LLM provider + key SecretRef)

Pure config manipulation (mode application, SecretRef construction, atomic
save/load) lives in src/setup-lib.ts and is covered by 21 unit tests. The
src/setup.ts CLI entry is a thin @clack/prompts wrapper on top.

Mode switches correctly clear stale fields from the opposite modes so a
user flipping between e.g. Cloud and Embedded doesn't end up with a mixed
configuration. All credentials are always written as env-backed SecretRef
objects, never plaintext.

Scanner-safe: neither setup.ts nor setup-lib.ts imports subprocess APIs or
reads environment variables, so the new files don't reintroduce the
dangerous-exec / env-harvesting findings that #974 just cleared.

* feat(openclaw): non-interactive setup flags + smoke test + CI

- setup.ts now accepts --mode cloud|api|embedded plus mode-specific flags
  (--api-url, --token-env, --no-token, --provider, --api-key-env, --model,
  --config-path) to skip the interactive TUI. Interactive remains the
  default when no --mode is given. main() is guarded by an isDirectRun()
  check so importing from tests does not trigger the wizard.

- src/setup.test.ts adds 23 unit tests covering every flag, invalid input
  (unknown flags, missing values, conflicting --token-env + --no-token,
  mode requirements) and the full non-interactive write path for each
  mode including cross-mode state cleanup.

- scripts/smoke-test.sh is a new end-to-end install smoke test:
  * packs a fresh tarball (or uses an existing one passed in argv[1])
  * installs via `openclaw plugins install <tarball>` WITHOUT
    --dangerously-force-unsafe-install — fails loudly if the scanner
    reports any findings
  * asserts workspace deps (@vectorize-io/hindsight-all, hindsight-client)
    resolved from the npm registry into the extension's node_modules
  * runs `hindsight-openclaw-setup` non-interactively for all 4 mode
    variants (cloud default URL, external API no-auth, embedded openai
    with model override, embedded claude-code no-key) and asserts
    `openclaw config validate` + `openclaw plugins doctor` pass after each
  * runs 3 negative tests to assert bad flag combinations fail fast
  * backs up and restores ~/.openclaw/openclaw.json around the run

- .github/workflows/test.yml adds a smoke-openclaw-install job on
  ubuntu-latest that installs the published `openclaw` CLI, rebuilds the
  workspace deps, and runs scripts/smoke-test.sh. Gated by the same
  detect-changes outputs as build-openclaw-integration and added to the
  report-pr-status needs list.

* chore(openclaw): point cloud mode at api.hindsight.vectorize.io, drop stale install.sh

- Replace the placeholder Hindsight Cloud URL with the real one,
  https://api.hindsight.vectorize.io, in setup-lib.ts and the three
  suites that hard-coded it (setup-lib.test.ts, setup.test.ts,
  scripts/smoke-test.sh).

- Delete hindsight-integrations/openclaw/install.sh. It predated
  `openclaw plugins install` and documented the pre-0.6.0 env-var flow
  ('export OPENAI_API_KEY', 'openclaw plugins enable'), which is
  superseded by the interactive/non-interactive hindsight-openclaw-setup
  wizard plus README quick start.

* fix(openclaw): smoke test — tolerate unrelated bundled-plugin diagnostics

In clean CI environments, `openclaw plugins doctor` can emit diagnostics
for bundled plugins (seen: "ollama: memory embedding provider already
registered") that have nothing to do with hindsight-openclaw. The
previous smoke-test check required the literal string "No plugin issues
detected" in doctor output, which treated those unrelated warnings as
failures.

Replace that check with two narrower ones: (a) `plugins doctor` must
exit zero, and (b) its output must not contain any line that mentions
hindsight together with fail/error/not-loaded. Unrelated bundled-plugin
warnings no longer fail the smoke test.

* docs(openclaw): document hindsight-openclaw-setup wizard

The plugin's own README was updated to lead with the setup wizard when
the feature landed, but the docs site page (docs-integrations/openclaw.md)
was still showing a Quick Start driven entirely by raw `openclaw config
set` commands. Update the Quick Start to mirror the README flow: install
the plugin, run `hindsight-openclaw-setup`, start the gateway. Include
the three modes (Cloud / External API / Embedded) and the non-interactive
--mode flag variants for CI.

Also add pointer notes at the top of the "LLM Configuration" and
"External API (Advanced)" sections so readers who arrived there directly
know the wizard already covers those paths.

Extend the 0.6.0 (Unreleased) changelog entry with the wizard under
**Features** and regenerate the skill mirror.

* fix(openclaw): resolve bin invocation when launched via npm symlink + doc the correct invocation

Two related problems found during end-to-end install testing:

1. `isDirectRun()` in setup.ts compared `process.argv[1]` against
   `fileURLToPath(import.meta.url)`. When the bin is invoked through
   `node_modules/.bin/hindsight-openclaw-setup` (an npm-created symlink
   into `dist/setup.js`), these two paths differ: argv[1] is the symlink
   and import.meta.url is the resolved target. The equality check failed,
   `main()` never ran, and the command silently exited with status 0 and
   no output. Canonicalize both via `realpathSync` before comparing —
   same approach the backfill bin already uses (`isDirectExecution` in
   src/backfill.ts).

2. `openclaw plugins install @vectorize-io/hindsight-openclaw` unpacks
   the plugin into ~/.openclaw/extensions/ but does not put its bins on
   $PATH, so the README/docs instruction `hindsight-openclaw-setup` was
   misleading — users would get "command not found". Update the Quick
   Start in both README.md and hindsight-docs/docs-integrations/openclaw.md
   to invoke the wizard via `npx --package @vectorize-io/hindsight-openclaw
   hindsight-openclaw-setup`, matching the existing invocation shown for
   the hindsight-openclaw-backfill bin.
2026-04-13 10:30:13 +02:00
Nicolò Boschi e5724fcba0 fix(embed): serialize daemon start and stop killing healthy daemons (#1016)
* fix(embed): serialize daemon start and stop killing healthy daemons

Two concurrent `hindsight-embed daemon start` calls used to kill each
other's freshly-started daemons: `_clear_port` unconditionally stopped
any hindsight daemon on the target port before spawning a new one, so
each caller detected the other's healthy daemon and SIGTERM'd it.

Two changes fix this at the source instead of requiring every
integration to serialize externally:

1. `_clear_port` no longer kills a *healthy* hindsight daemon. If
   /health returns 200, return True and reuse the existing daemon.
   Only reclaim the port when the listener is unhealthy (stale from a
   version upgrade or a crash), matching the original stated intent.

2. `_start_daemon` now holds an exclusive flock on the profile's lock
   file for the whole startup sequence, and re-checks `is_running()`
   inside the lock. Concurrent callers serialize on the flock; the
   waiter returns immediately once the winner's daemon is up. The
   post-_clear_port `is_running()` check also prevents spawning a
   second daemon if a foreign-started daemon showed up mid-flight.

Tests updated: two existing tests codified the old kill-on-healthy
behavior; they now assert the new reuse behavior. Added new tests for
unhealthy-daemon reclamation and for the serialization/double-check
paths.

* style(retain): reformat ann seeds sql calls onto single lines
2026-04-13 10:22:55 +02:00
Nicolò Boschi 848451bd01 docs(mental-models): clarify that tags filter refresh source memories (#1013)
Addresses #945 and the related confusion in #1004. The mental model
`tags` field acts as a hard `all_strict` filter on source memories
during refresh, but this wasn't obvious from the parameter tables
or the UI form — users hit empty refresh content while direct reflect
on the same query worked.

- Expand the `tags` parameter description in the mental-models API
  doc and mirror it in the skills reference.
- Add a warning callout in the "Tags and Visibility" section pointing
  users at backfill / trigger.tags_match / tag_groups workarounds.
- Add helper text under the Tags input (both Create and Edit forms)
  in the control plane mental-models view.
2026-04-13 09:51:19 +02:00
Nicolò Boschi f82f58fa83 fix(reranker): surface real import errors and fix transformers 5.x race in jina-mlx (#994)
* fix(reranker): surface real import errors and fix transformers 5.x race in jina-mlx

Two fixes for jina-mlx reranker startup on Apple Silicon (#994):

1. Pre-warm transformers.AutoTokenizer before importing mlx_lm. transformers 5.x
   uses _LazyModule and has an unguarded window where concurrent imports from
   another thread (e.g. local embeddings init in an executor) can cause
   `from transformers import AutoTokenizer` inside mlx_lm's tokenizer_utils to
   raise ImportError.

2. Narrow the `except ImportError` so unrelated transitive failures inside
   mlx_lm propagate verbatim with chained traceback. The previous bare except
   masked the real error with a misleading "install mlx" message even when
   mlx and mlx_lm were correctly installed.

* fix(tests): stub mlx modules for jina-mlx import test + sync link_utils lint format

- Stub mlx and mlx.core in sys.modules so test_initialize_surfaces_transitive_import_error
  works in CI environments where mlx is not installed (CI's import mlx.core was failing
  before the patched __import__ ever saw mlx_lm, hitting the install-hint branch).
- Apply the lint reformat to link_utils.py that lint.sh produces; verify-generated-files
  was failing because the committed file didn't match lint output.
2026-04-13 09:48:28 +02:00
Nicolò Boschi 2d74007d80 fix(worker): reserve consolidation slots within max_slots (#1006) (#1012)
Consolidation tasks were sharing the same slot pool as retain and could only
claim leftover slots. With a continuous retain queue, retains saturated
max_slots and consolidation was permanently starved.

Make consolidation_max_slots a true reservation: non-consolidation tasks may
use at most (max_slots - consolidation_max_slots) slots, leaving the remainder
always available for consolidation. Also inject operation_type on claimed
consolidation rows so in-flight tracking works (the JSON payload didn't carry
the field, so _in_flight_by_type["consolidation"] was never incremented).

Adds a regression test that submits 10 retains + 1 consolidation with
max_slots=5, consolidation_max_slots=2 and verifies retain caps at 3 while
consolidation still claims its slot. Existing retain-only saturation tests
updated to set consolidation_max_slots=0.

Docs clarify the reservation semantics in configuration.md.
2026-04-13 09:42:43 +02:00
Nicolò Boschi 05686e1236 docs: clarify audit logging is off by default (#944) (#1008)
* docs: clarify audit logging is off by default (#944)

Explains that /audit-logs returns empty until HINDSIGHT_API_AUDIT_LOG_ENABLED=true, which was the confusion reported in the issue.

* docs: regenerate skill mirror for audit logging section
2026-04-13 09:32:11 +02:00
Nicolò Boschi 93300b9104 fix(cli): surface HTTP response body in API errors (#1011)
Previously `hindsight memory retain/recall/reflect` errors rendered as
"Unexpected Response: Response { ... }" with no body, hiding the actual
validation detail (e.g. FastAPI's `{"detail": "..."}` payload). Users had
to fall back to `curl` to see why a request failed.

Adds a helper that unpacks progenitor's `ErrorResponse`,
`UnexpectedResponse`, and `InvalidResponsePayload` variants and includes
the response body in the error message.

Refs #1007.
2026-04-13 09:30:39 +02:00
Nicolò Boschi 9402572339 fix(embed): restore macOS FORCE_CPU default for local embeddings/reranker (#1010)
* fix(embed): restore macOS FORCE_CPU default for local embeddings/reranker

PR #933 (0.5.0) removed the unconditional macOS CPU-force block from
DaemonEmbedManager._start_daemon. The block was the actual mechanism
that reached the daemon subprocess env — the profile .env value written
by `hindsight-embed configure` does not propagate, because _start_daemon
only copies a whitelist of keys (llm_*, log_level, idle_timeout) into
the subprocess env.

Net effect on 0.5.0 + macOS Apple Silicon: sentence-transformers
auto-selects MPS, daemon init hangs, startup times out.

Restore the block so FORCE_CPU is set by default on Darwin, while still
honoring an explicit user override (e.g. FORCE_CPU=0 to opt into MPS).

Fixes #962

* fix(embed): propagate all HINDSIGHT_* keys from profile config to daemon env

The daemon env builder only copied a whitelist of keys (llm_*, log_level,
idle_timeout) from the merged profile config. Any other HINDSIGHT_* key
written to the profile's .env — e.g. HINDSIGHT_API_EMBEDDINGS_PROVIDER,
HINDSIGHT_API_EMBEDDINGS_TEI_URL, or the FORCE_CPU flags on non-macOS —
was silently dropped when spawning the daemon subprocess.

Pass the full set of HINDSIGHT_* keys through after the whitelist loop,
so profile-level settings actually reach the daemon.
2026-04-13 09:27:34 +02:00
PaulKnag e9cc771bbd fix(recall): use async generate_embeddings_batch for query embedding (#999)
The recall hot path in _search_with_retries calls
embedding_utils.generate_embedding() synchronously, which runs
sentence-transformers GPU inference on the asyncio event loop thread.
This blocks /health and all concurrent requests for the duration of
each embedding call. Under consolidation load (WorkerPoller runs
in-process with 2 concurrent slots), stacked sync embedding calls
cause /health to exceed watchdog timeouts and trigger destructive
service restarts.

Replace the single sync generate_embedding() call with the async
generate_embeddings_batch() wrapper that already exists in the same
codebase and is used correctly at 3 other call sites in this file
(lines 5469, 6655, 6877). The batch wrapper offloads GPU inference
to a thread pool via run_in_executor, keeping the event loop free.

This was the only remaining sync embedding call in memory_engine.py.
2026-04-13 09:12:02 +02:00
Octopusandocto-patch 2a2b90b0a0 test(config): add regression test for entity_labels format validation (fixes #946) (#1005)
Previously, PATCH /v1/default/banks/{id}/config accepted malformed
entity_labels (e.g. plain strings instead of LabelGroup dicts) with
HTTP 200, then failed with a 500 on the next retain call. The fix in
PR #902 added validation to config_resolver.update_bank_config, but
no regression test was added to prevent a future regression.

This commit adds a focused test that:
- Asserts that a string list (["person", "client"]) raises ValueError
  with "Invalid entity_labels format" rather than being silently stored
- Asserts that a correctly shaped LabelGroup list succeeds

Co-authored-by: octo-patch <[email protected]>
2026-04-13 09:08:34 +02:00
r266-tech 2635bbb49e fix(cli): memory list shows [UNKNOWN] for all fact types (#998)
* fix(cli): read fact_type key in memory list/get pretty output

The API response uses the key 'fact_type' but the CLI formatter reads
'type', causing every memory to display as [UNKNOWN]. Also fixes the
serde rename on MemoryUnitDetail and adds 'observation' match arm.

* fix(cli): add observation and experience match arms to print_fact gradient
2026-04-13 09:07:08 +02:00
r266-tech 2e88bac605 test(reflect): regression test for internal billing in sub-recalls (#972) (#989)
PR #972 fixed double-billing by marking reflect's internal recall calls
as internal=True. Add 4 focused tests to prevent regression:

- search_observations passes internal=True to recall_async
- tool_recall passes internal=True to recall_async
- Neither function mutates the original request context

Fixes #988
2026-04-13 09:04:05 +02:00
r266-tech 2644930561 docs(cli): document webhook, audit, operation, and memory history subcommands (#983)
PR #968 added full OpenAPI endpoint coverage (46/62 → 62/62) but
cli.md was not updated. Add sections for:

- Webhook management (list/create/update/delete/deliveries)
- Audit logs (list with action/transport/date filters)
- Operation management (list/get/cancel/retry)
- Memory history and clear-observations
- Document update
- Bank set-disposition and consolidation-recover
- New flags on recall (--tags, --query-timestamp) and reflect (--fact-types)

Fixes #982
2026-04-13 09:01:53 +02:00
ooa-andera bbd3c5dc04 docs: add ContextForge MCP gateway integration (#961)
Add ContextForge as a community integration. ContextForge (IBM) is an
open-source MCP gateway that aggregates multiple MCP servers behind a
single authenticated endpoint.

This integration registers Hindsight's built-in /mcp endpoint as a
gateway backend in ContextForge, giving every connected AI tool (Dust,
Claude Desktop, custom agents) access to retain, recall, and reflect
tools through a unified MCP hub.

- Add integration entry to integrations.json (community, mcp category)
- Add docs page with setup guide (UI, API, Helm auto-registration)
- Add sidebar link

Tested end-to-end locally: ContextForge discovers all 30 Hindsight MCP
tools and can execute them through the gateway.
2026-04-13 09:00:38 +02:00
akhaterandakhater 4f9cf15cdd fix(recall): preserve RRF ranking when reranker is a passthrough (#957)
The slim deployment default (`reranker_provider=rrf`,
`RRFPassthroughCrossEncoder`) returns a constant 0.5 score for every
candidate. After sigmoid normalisation that becomes a constant
`cross_encoder_score_normalized` across all candidates, so the
multiplicative recency / temporal / proof_count boosts inside
`apply_combined_scoring` become the *only* ranking signal.

For non-temporal queries on `world` facts the temporal and proof_count
boosts collapse to 1.0, leaving `recency_boost` alone. The final
ordering is then a pure newest-first sort, regardless of how relevant a
candidate is to the query — and `rrf_normalized` is explicitly set to
0.0 a few lines above, so the upstream RRF rank is discarded entirely.

In practice this means any biographical / historical / long-tail world
fact (anything with an old `occurred_start`) is guaranteed to lose to a
recent fact in the candidate set, even when RRF, BM25, semantic search
*and* graph traversal all agree it should be the top result.

## Repro

A `world` fact with `occurred_start` ~30 years in the past, indexed
alongside a few thousand recent observations and world facts in the
same bank, is correctly identified as the top match by every retrieval
arm:

```
semantic   (world): 1000 items | target rank 1
bm25       (world): 1000 items | target rank 1
graph      (world):  346 items | target visited
RRF merged       :  1673 items | target rank 1
```

After reranking with the passthrough cross-encoder it lands at rank 80,
and the token-budget filter then drops it from the response entirely.
The same pattern reproduces for every query phrasing tested (short,
long, with and without entity names).

## Fix

Detect the degenerate-CE case in `apply_combined_scoring` and seed
`cross_encoder_score_normalized` from the RRF rank before the boosts
are applied. The boosts then modulate a meaningful base instead of
replacing it.

- No-op for real cross-encoders (`flashrank`, `local`, `cohere`,
  `litellm`, …) — those produce diverse scores so the `len(set(...)) <= 1`
  guard never triggers.
- No schema, embedding, or API changes.
- Recency / temporal / proof_count boosts are still applied on top, so
  ranking ties between adjacent RRF candidates can still be broken by
  the secondary signals.

## After fix

Same database, same queries, target fact moves from "dropped from
response" to a stable top-10 position across every query variation
tested.

Co-authored-by: akhater <[email protected]>
2026-04-13 08:59:48 +02:00
Nicolò Boschi 2d95f78b09 fix(retain): make chunk insert idempotent and stop retrying integrity errors (#986)
Two related fixes for retain re-submission failures:

1. store_chunks_batch now upserts via ON CONFLICT (chunk_id) DO UPDATE.
   Re-submitting a retain under the same document_id (the pattern in #977)
   previously failed with UniqueViolationError on pk_chunks when any
   upstream path — cascade-delete on is_first_batch, delta-retain chunk
   diff, concurrent worker tasks — didn't clean up before the insert.
   Overwriting is the correct semantics for document_id as a grouping key.

2. MemoryEngine.execute_task now classifies asyncpg
   IntegrityConstraintViolationError subclasses as non-retryable (#980).
   Previously the poller retried them ~3 times over ~3 minutes, burning
   worker capacity on a deterministic error that will never succeed.

Fixes vectorize-io/hindsight#977, vectorize-io/hindsight#980
2026-04-13 08:58:39 +02:00
Nicolò Boschi 773ef0cb63 test(cloudflare-oauth-proxy): add tests, CI, and security hardening (#975)
Follow-up to #922. The initial PR was merged without the tests, CI
job, or release-script entry that CLAUDE.md mandates for new
integrations, and the source had a handful of code-quality issues
flagged in review.

Testing & CI
- Split src/index.ts into env/html/cors/proxy/auth/router modules so
  each unit can be exercised in plain Node without the Workers runtime
- Add 50 vitest tests covering html escaping, CORS application /
  stripping, the /authorize GET+POST flow with a mocked OAuth provider,
  the MCP proxy's header sanitisation, and the outer router's
  preflight + metadata hardening
- Add tsconfig.json, vitest.config.ts, typecheck+test scripts, and a
  test-cloudflare-oauth-proxy-integration job wired into detect-changes
  and report-pr-status
- Add cloudflare-oauth-proxy to VALID_INTEGRATIONS

Hardening
- Remove `any` types; introduce an explicit OAuthHelpers interface
- Replace the plain `!==` password check with a constant-time
  SHA-256-based comparison
- Drop the PII (email) log line from the MCP proxy
- CORS: list explicit methods instead of `*`, include `Mcp-Session-Id`
  in Allow-Headers, emit `Vary: Origin`
- Proxy: strip client Authorization + X-Proxy-Secret + hop-by-hop
  headers, filter upstream response headers through an allowlist
  (drops Set-Cookie and upstream CORS), buffer request body to avoid
  needing `duplex: "half"`
- Override OAuth metadata to advertise S256 only
- README: align PKCE wording with reality and document the single-user
  threat model; wrangler.toml defaults to workers_dev=false
2026-04-13 08:58:24 +02:00
Nicolò Boschi 7b2263ba3b fix(llm): send max_completion_tokens for reasoning models and Azure OpenAI (#979)
PR #858 made the openai provider fall back to max_tokens whenever a custom
base_url was set, to support Mistral/Together-style endpoints. This regressed
two important setups:

1. Reasoning models (GPT-5, o1, o3) reject max_tokens outright with a 400
   ("Unsupported parameter: 'max_tokens' is not supported with this model.
   Use 'max_completion_tokens' instead.").
2. Azure OpenAI is fully OpenAI-API-compatible — it was only classified as
   "third-party compatible" because it requires a custom base_url.

The combination of the two — Azure OpenAI + GPT-5 — is the exact setup the
reporter hit in issue #978 and fails connection verification on startup.

Fix _max_tokens_param_name() so it:

- Always returns max_completion_tokens for reasoning models, regardless of
  base_url (they only support the new parameter name).
- Detects Azure OpenAI endpoints by the *.openai.azure.com hostname and
  treats them as native OpenAI.

The Mistral/Together behavior from #858 is preserved for non-reasoning
models on non-Azure custom base URLs.

Fixes #978
2026-04-13 08:56:16 +02:00
429 changed files with 15968 additions and 2367 deletions
@@ -82,6 +82,15 @@ jobs:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
# Guard: fail fast if the integration's lockfile resolves any dep from a
# monorepo workspace (link=true) or a relative file path. The release
# runner has no pre-built workspace `dist/` so `npm run build` would
# later fail at tsc with "Cannot find module". See:
# https://github.com/vectorize-io/hindsight/issues/… (0.6.0 openclaw retry)
- name: Check integration lockfile
if: steps.type.outputs.type == 'typescript'
run: ./scripts/check-integration-lockfiles.sh
- name: Install dependencies
if: steps.type.outputs.type == 'typescript'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
+1 -1
View File
@@ -540,7 +540,7 @@ jobs:
ls -la release-assets/
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: release-assets/*
generate_release_notes: true
+120
View File
@@ -47,6 +47,8 @@ jobs:
integrations-llamaindex: ${{ steps.filter.outputs.integrations-llamaindex }}
integrations-paperclip: ${{ steps.filter.outputs.integrations-paperclip }}
integrations-opencode: ${{ steps.filter.outputs.integrations-opencode }}
integrations-cloudflare-oauth-proxy: ${{ steps.filter.outputs.integrations-cloudflare-oauth-proxy }}
integrations-lockfiles: ${{ steps.filter.outputs.integrations-lockfiles }}
dev: ${{ steps.filter.outputs.dev }}
ci: ${{ steps.filter.outputs.ci }}
# Secrets are available for internal PRs, pull_request_review, and workflow_dispatch.
@@ -125,11 +127,40 @@ jobs:
- 'hindsight-integrations/paperclip/**'
integrations-opencode:
- 'hindsight-integrations/opencode/**'
integrations-cloudflare-oauth-proxy:
- 'hindsight-integrations/cloudflare-oauth-proxy/**'
integrations-lockfiles:
- 'hindsight-integrations/*/package-lock.json'
- 'hindsight-integrations/*/package.json'
- 'scripts/check-integration-lockfiles.sh'
dev:
- 'hindsight-dev/**'
ci:
- '.github/**'
# Fail fast if any hindsight-integrations/*/package-lock.json was regenerated
# from the monorepo root and ended up symlinked at a workspace path instead
# of the npm registry. That bit us on the 0.6.0 openclaw release — tsc in
# the release workflow couldn't find `@vectorize-io/hindsight-client`
# because its `resolved` url pointed at a workspace dir whose `dist/` was
# gitignored and unbuilt. Catching this at PR time means the release CI
# never hits that class of failure.
check-integration-lockfiles:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-lockfiles == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Check integration lockfiles resolve from the npm registry
run: ./scripts/check-integration-lockfiles.sh
build-api-python-versions:
needs: [detect-changes]
if: >-
@@ -271,6 +302,61 @@ jobs:
working-directory: ./hindsight-integrations/openclaw
run: npm test
smoke-openclaw-install:
needs: [detect-changes, build-openclaw-integration]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-openclaw == 'true' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
needs.detect-changes.outputs.all-npm == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json
# Install the openclaw CLI globally. The smoke test exercises the real
# `openclaw plugins install` / `openclaw config set` / `openclaw plugins
# doctor` commands — not the in-repo integration tests — so a real CLI
# must be on PATH.
- name: Install openclaw CLI
run: npm install -g openclaw
- name: Verify openclaw CLI
run: openclaw --version
# openclaw depends on the workspace packages via published version
# ranges (^0.1.0 / ^0.5.0), not file: paths, so the smoke test's
# `openclaw plugins install <tarball>` resolves them straight from the
# npm registry. These builds are just for `npm pack` / local unit
# tests, not for resolving the plugin's runtime deps.
- name: Install root workspace dependencies
run: npm ci
- name: Build hindsight-client (openclaw dep)
run: npm run build --workspace=hindsight-clients/typescript
- name: Build hindsight-all-npm (openclaw dep)
run: npm run build --workspace=hindsight-all-npm
- name: Install openclaw dependencies
working-directory: ./hindsight-integrations/openclaw
run: npm ci
- name: Run openclaw install smoke test
working-directory: ./hindsight-integrations/openclaw
run: ./scripts/smoke-test.sh
test-claude-code-integration:
needs: [detect-changes]
if: >-
@@ -417,6 +503,37 @@ jobs:
working-directory: ./hindsight-integrations/opencode
run: npm run build
test-cloudflare-oauth-proxy-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-cloudflare-oauth-proxy == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install dependencies
working-directory: ./hindsight-integrations/cloudflare-oauth-proxy
run: npm ci
- name: Typecheck
working-directory: ./hindsight-integrations/cloudflare-oauth-proxy
run: npm run typecheck
- name: Run tests
working-directory: ./hindsight-integrations/cloudflare-oauth-proxy
run: npm test
build-chat-integration:
needs: [detect-changes]
if: >-
@@ -2551,14 +2668,17 @@ jobs:
if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved' && always()
needs:
- detect-changes
- check-integration-lockfiles
- build-api-python-versions
- build-typescript-client
- build-openclaw-integration
- smoke-openclaw-install
- test-claude-code-integration
- test-codex-integration
- build-ai-sdk-integration
- test-ai-sdk-integration-deno
- test-opencode-integration
- test-cloudflare-oauth-proxy-integration
- build-chat-integration
- test-paperclip-integration
- build-control-plane
+4
View File
@@ -222,6 +222,10 @@ Every new integration in `hindsight-integrations/` must satisfy all of the follo
If any of these are missing, the integration is incomplete and must not be pushed or merged.
### Changelogs
Never add "Unreleased" entries to changelogs (e.g. `hindsight-docs/src/pages/changelog/**`). Changelog entries are written by the release script (`./scripts/release-integration.sh`) when a version is actually cut. If a bug fix or feature needs documenting before release, describe it in the PR/commit — the release tooling will surface it in the published changelog section.
### Adding New API Configuration Flags
Configuration follows a hierarchical system: **Global (env vars) → Tenant (via extension) → Bank (database)**.
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.5.0
appVersion: "0.5.0"
version: 0.5.1
appVersion: "0.5.1"
keywords:
- ai
- memory
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-all",
"version": "0.5.0",
"version": "0.5.1",
"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",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-all-slim"
version = "0.5.0"
version = "0.5.1"
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
+24 -2
View File
@@ -190,12 +190,32 @@ class HindsightEmbedded:
if self._closed:
return
with self._lock:
acquired = self._lock.acquire(timeout=5.0)
if not acquired:
# Lock is held by another thread (e.g. _ensure_started).
# Mark closed to prevent new operations but skip shared-state
# teardown — the daemon's idle timeout handles the rest.
logger.warning(
"Cleanup lock acquisition timed out for profile '%s'; "
"marking closed, daemon will idle-stop on its own",
self.profile,
)
self._closed = True
return
try:
if self._closed:
return
if self._client is not None:
self._client.close()
try:
self._client.close()
except Exception:
logger.debug(
"Error closing client for profile '%s'",
self.profile,
exc_info=True,
)
self._client = None
# Stop UI if it was started
@@ -209,6 +229,8 @@ class HindsightEmbedded:
self._manager.stop(self.profile)
self._closed = True
finally:
self._lock.release()
def close(self, stop_daemon: bool = False):
"""
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-all"
version = "0.5.0"
version = "0.5.1"
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
@@ -0,0 +1,56 @@
"""
Unit test for _cleanup lock timeout behavior.
Verifies that _cleanup completes even when the lock is held by another thread,
instead of hanging indefinitely (fixes #952).
"""
import threading
import time
from unittest.mock import MagicMock, patch
import pytest
def test_cleanup_completes_when_lock_held():
"""
_cleanup should complete (best-effort) even when self._lock is held
by another thread, e.g. during a long _ensure_started call.
"""
with patch.dict("sys.modules", {
"hindsight_client": MagicMock(),
"hindsight_embed": MagicMock(),
"hindsight.api_namespaces": MagicMock(),
}):
from hindsight.embedded import HindsightEmbedded
client = HindsightEmbedded.__new__(HindsightEmbedded)
client.profile = "test"
client._lock = threading.Lock()
client._closed = False
client._client = None
client._started = False
client._ui = False
# Simulate another thread holding the lock
client._lock.acquire()
cleanup_done = threading.Event()
def run_cleanup():
client._cleanup()
cleanup_done.set()
t = threading.Thread(target=run_cleanup)
t.start()
# Cleanup should complete within the timeout (5s) + margin
assert cleanup_done.wait(timeout=8.0), (
"_cleanup hung instead of timing out on lock acquisition"
)
# Release the lock from the simulating thread
client._lock.release()
t.join(timeout=1.0)
assert client._closed, "Client should be marked as closed after cleanup"
+1 -1
View File
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.5.0"
__version__ = "0.5.1"
+65 -2
View File
@@ -1526,6 +1526,27 @@ class MentalModelTrigger(BaseModel):
"Supports nested and/or/not expressions for complex tag-based scoping."
),
)
include_chunks: bool | None = Field(
default=None,
description=(
"Override whether the internal recall used during refresh returns raw chunk text. "
"None means use the bank/global config default (recall_include_chunks)."
),
)
recall_max_tokens: int | None = Field(
default=None,
description=(
"Override the token budget for facts returned by the internal recall during refresh. "
"None means use the bank/global config default (recall_max_tokens)."
),
)
recall_chunks_max_tokens: int | None = Field(
default=None,
description=(
"Override the token budget for raw chunks returned by the internal recall during refresh. "
"None means use the bank/global config default (recall_chunks_max_tokens)."
),
)
@field_validator("fact_types")
@classmethod
@@ -1673,6 +1694,36 @@ class BankTemplateConfig(BaseModel):
entities_allow_free_form: bool | None = Field(
default=None, description="Allow entities outside the label vocabulary"
)
retain_default_strategy: str | None = Field(
default=None, description="Name of the default retain strategy (key into retain_strategies map)"
)
retain_strategies: dict | None = Field(
default=None, description="Map of retain strategy name to per-strategy config dict"
)
retain_chunk_batch_size: int | None = Field(
default=None, description="Max chunks per streaming batch (0 disables batching)"
)
mcp_enabled_tools: list[str] | None = Field(
default=None, description="MCP tool allowlist for this bank (None = all tools)"
)
consolidation_llm_batch_size: int | None = Field(
default=None, description="LLM batch size for observation consolidation"
)
consolidation_source_facts_max_tokens: int | None = Field(
default=None, description="Max tokens of source facts per consolidation batch"
)
consolidation_source_facts_max_tokens_per_observation: int | None = Field(
default=None, description="Max tokens of source facts per observation"
)
max_observations_per_scope: int | None = Field(
default=None, description="Max observations to retain per consolidation scope"
)
reflect_source_facts_max_tokens: int | None = Field(
default=None, description="Max tokens of source facts per reflect call"
)
llm_gemini_safety_settings: list | None = Field(
default=None, description="Per-bank Gemini/VertexAI safety filter settings"
)
def get_config_updates(self) -> dict[str, Any]:
"""Return only the fields that were explicitly set (non-None)."""
@@ -2084,6 +2135,10 @@ class OperationStatusResponse(BaseModel):
child_operations: list[ChildOperationStatus] | None = Field(
default=None, description="Child operations for batch operations (if applicable)"
)
task_payload: dict[str, Any] | None = Field(
default=None,
description="Raw task payload (params the operation was submitted with). Only populated when include_payload=true.",
)
class AsyncOperationSubmitResponse(BaseModel):
@@ -4168,7 +4223,13 @@ def _register_routes(app: FastAPI):
tags=["Operations"],
)
async def api_get_operation_status(
bank_id: str, operation_id: str, request_context: RequestContext = Depends(get_request_context)
bank_id: str,
operation_id: str,
include_payload: bool = Query(
default=False,
description="Include the raw task payload (submission params) in the response. May be large.",
),
request_context: RequestContext = Depends(get_request_context),
):
"""Get the status of an async operation."""
try:
@@ -4178,7 +4239,9 @@ def _register_routes(app: FastAPI):
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid operation_id format: {operation_id}")
result = await app.state.memory.get_operation_status(bank_id, operation_id, request_context=request_context)
result = await app.state.memory.get_operation_status(
bank_id, operation_id, request_context=request_context, include_payload=include_payload
)
return OperationStatusResponse(**result)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
@@ -247,6 +247,11 @@ ENV_RERANKER_ZEROENTROPY_API_KEY = "HINDSIGHT_API_RERANKER_ZEROENTROPY_API_KEY"
ENV_RERANKER_ZEROENTROPY_MODEL = "HINDSIGHT_API_RERANKER_ZEROENTROPY_MODEL"
ENV_RERANKER_ZEROENTROPY_BASE_URL = "HINDSIGHT_API_RERANKER_ZEROENTROPY_BASE_URL"
# SiliconFlow configuration (reranker only; Cohere-compatible /rerank endpoint)
ENV_RERANKER_SILICONFLOW_API_KEY = "HINDSIGHT_API_RERANKER_SILICONFLOW_API_KEY"
ENV_RERANKER_SILICONFLOW_MODEL = "HINDSIGHT_API_RERANKER_SILICONFLOW_MODEL"
ENV_RERANKER_SILICONFLOW_BASE_URL = "HINDSIGHT_API_RERANKER_SILICONFLOW_BASE_URL"
# Google Discovery Engine reranker configuration
ENV_RERANKER_GOOGLE_MODEL = "HINDSIGHT_API_RERANKER_GOOGLE_MODEL"
ENV_RERANKER_GOOGLE_PROJECT_ID = "HINDSIGHT_API_RERANKER_GOOGLE_PROJECT_ID"
@@ -382,6 +387,9 @@ ENV_REFLECT_MAX_CONTEXT_TOKENS = "HINDSIGHT_API_REFLECT_MAX_CONTEXT_TOKENS"
ENV_REFLECT_WALL_TIMEOUT = "HINDSIGHT_API_REFLECT_WALL_TIMEOUT"
ENV_REFLECT_MISSION = "HINDSIGHT_API_REFLECT_MISSION"
ENV_REFLECT_SOURCE_FACTS_MAX_TOKENS = "HINDSIGHT_API_REFLECT_SOURCE_FACTS_MAX_TOKENS"
ENV_RECALL_INCLUDE_CHUNKS = "HINDSIGHT_API_RECALL_INCLUDE_CHUNKS"
ENV_RECALL_MAX_TOKENS = "HINDSIGHT_API_RECALL_MAX_TOKENS"
ENV_RECALL_CHUNKS_MAX_TOKENS = "HINDSIGHT_API_RECALL_CHUNKS_MAX_TOKENS"
# Audit log settings
ENV_AUDIT_LOG_ENABLED = "HINDSIGHT_API_AUDIT_LOG_ENABLED"
@@ -474,6 +482,9 @@ DEFAULT_RERANKER_OPENROUTER_MODEL = "cohere/rerank-v3.5"
DEFAULT_RERANKER_ZEROENTROPY_MODEL = "zerank-2"
DEFAULT_RERANKER_SILICONFLOW_MODEL = "BAAI/bge-reranker-v2-m3"
DEFAULT_RERANKER_SILICONFLOW_BASE_URL = "https://api.siliconflow.cn/v1"
DEFAULT_RERANKER_GOOGLE_MODEL = "semantic-ranker-default-004"
# Vector extension (pgvector, vchord, or pgvectorscale)
@@ -579,6 +590,9 @@ DEFAULT_REFLECT_MAX_ITERATIONS = 10 # Max tool call iterations before forcing r
DEFAULT_REFLECT_MAX_CONTEXT_TOKENS = 100_000 # Max accumulated context tokens before forcing final prompt
DEFAULT_REFLECT_WALL_TIMEOUT = 300 # Wall-clock timeout in seconds for the entire reflect operation (5 minutes)
DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS = -1 # Token budget for source facts in search_observations (-1 = disabled)
DEFAULT_RECALL_INCLUDE_CHUNKS = True # Whether internal recall (e.g. mental model refresh) returns raw chunks
DEFAULT_RECALL_MAX_TOKENS = 2048 # Token budget for facts returned by internal recall
DEFAULT_RECALL_CHUNKS_MAX_TOKENS = 1000 # Token budget for raw chunks returned by internal recall
# Disposition defaults (None = not set, fall back to bank DB value or 3)
DEFAULT_DISPOSITION_SKEPTICISM = None
@@ -828,6 +842,9 @@ class HindsightConfig:
reranker_zeroentropy_api_key: str | None
reranker_zeroentropy_model: str
reranker_zeroentropy_base_url: str | None
reranker_siliconflow_api_key: str | None
reranker_siliconflow_model: str
reranker_siliconflow_base_url: str
reranker_google_model: str
reranker_google_project_id: str | None
reranker_google_service_account_key: str | None
@@ -914,6 +931,11 @@ class HindsightConfig:
reflect_mission: str | None
reflect_source_facts_max_tokens: int
# Recall settings (used by internal recall, e.g. during mental model refresh)
recall_include_chunks: bool
recall_max_tokens: int
recall_chunks_max_tokens: int
# Disposition settings (hierarchical - can be overridden per bank; None = fall back to DB)
disposition_skepticism: int | None
disposition_literalism: int | None
@@ -984,6 +1006,7 @@ class HindsightConfig:
"reranker_tei_base_url",
"reranker_cohere_base_url",
"reranker_zeroentropy_base_url",
"reranker_siliconflow_base_url",
# Service Account Keys
"llm_vertexai_service_account_key",
"embeddings_vertexai_service_account_key",
@@ -1026,6 +1049,10 @@ class HindsightConfig:
# Reflect settings
"reflect_mission",
"reflect_source_facts_max_tokens",
# Recall settings (used by internal recall, e.g. mental model refresh)
"recall_include_chunks",
"recall_max_tokens",
"recall_chunks_max_tokens",
# Disposition settings
"disposition_skepticism",
"disposition_literalism",
@@ -1352,6 +1379,12 @@ class HindsightConfig:
reranker_zeroentropy_api_key=os.getenv(ENV_RERANKER_ZEROENTROPY_API_KEY),
reranker_zeroentropy_model=os.getenv(ENV_RERANKER_ZEROENTROPY_MODEL, DEFAULT_RERANKER_ZEROENTROPY_MODEL),
reranker_zeroentropy_base_url=os.getenv(ENV_RERANKER_ZEROENTROPY_BASE_URL) or None,
# SiliconFlow reranker (Cohere-compatible /rerank endpoint)
reranker_siliconflow_api_key=os.getenv(ENV_RERANKER_SILICONFLOW_API_KEY),
reranker_siliconflow_model=os.getenv(ENV_RERANKER_SILICONFLOW_MODEL, DEFAULT_RERANKER_SILICONFLOW_MODEL),
reranker_siliconflow_base_url=os.getenv(
ENV_RERANKER_SILICONFLOW_BASE_URL, DEFAULT_RERANKER_SILICONFLOW_BASE_URL
),
# Google Discovery Engine reranker (with fallback to LLM Vertex AI keys)
reranker_google_model=os.getenv(ENV_RERANKER_GOOGLE_MODEL, DEFAULT_RERANKER_GOOGLE_MODEL),
reranker_google_project_id=os.getenv(ENV_RERANKER_GOOGLE_PROJECT_ID)
@@ -1505,6 +1538,12 @@ class HindsightConfig:
reflect_source_facts_max_tokens=int(
os.getenv(ENV_REFLECT_SOURCE_FACTS_MAX_TOKENS, str(DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS))
),
recall_include_chunks=os.getenv(ENV_RECALL_INCLUDE_CHUNKS, str(DEFAULT_RECALL_INCLUDE_CHUNKS)).lower()
in ("true", "1", "yes"),
recall_max_tokens=int(os.getenv(ENV_RECALL_MAX_TOKENS, str(DEFAULT_RECALL_MAX_TOKENS))),
recall_chunks_max_tokens=int(
os.getenv(ENV_RECALL_CHUNKS_MAX_TOKENS, str(DEFAULT_RECALL_CHUNKS_MAX_TOKENS))
),
# Disposition settings (None = fall back to DB value)
disposition_skepticism=int(os.getenv(ENV_DISPOSITION_SKEPTICISM))
if os.getenv(ENV_DISPOSITION_SKEPTICISM)
@@ -30,6 +30,8 @@ from ..config import (
DEFAULT_RERANKER_LOCAL_MODEL,
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE,
DEFAULT_RERANKER_PROVIDER,
DEFAULT_RERANKER_SILICONFLOW_BASE_URL,
DEFAULT_RERANKER_SILICONFLOW_MODEL,
DEFAULT_RERANKER_TEI_BATCH_SIZE,
DEFAULT_RERANKER_TEI_MAX_CONCURRENT,
DEFAULT_RERANKER_ZEROENTROPY_MODEL,
@@ -44,6 +46,7 @@ from ..config import (
ENV_RERANKER_LOCAL_MODEL,
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE,
ENV_RERANKER_PROVIDER,
ENV_RERANKER_SILICONFLOW_API_KEY,
ENV_RERANKER_TEI_BATCH_SIZE,
ENV_RERANKER_TEI_MAX_CONCURRENT,
ENV_RERANKER_TEI_URL,
@@ -518,6 +521,84 @@ class RemoteTEICrossEncoder(CrossEncoderModel):
return await self._predict_async(pairs)
class _CohereCompatibleRerankClient:
"""
Internal HTTP client for Cohere-compatible /rerank endpoints.
Shared by all providers that speak the Cohere rerank wire format —
{model, query, documents[, top_n]} request and
{results: [{index, relevance_score}, ...]} response. This covers
SiliconFlow, ZeroEntropy, Jina, Voyage, BGE self-hosted, and Cohere
itself when reached via a custom base_url (e.g. Azure AI Foundry).
Not a CrossEncoderModel — providers compose it and expose their own
provider_name / initialization logging.
"""
def __init__(
self,
api_key: str,
model: str,
rerank_url: str,
timeout: float = 60.0,
include_top_n: bool = True,
):
self.api_key = api_key
self.model = model
self.rerank_url = rerank_url
self.timeout = timeout
self.include_top_n = include_top_n
self._async_client: httpx.AsyncClient | None = None
async def initialize(self) -> None:
if self._async_client is not None:
return
self._async_client = httpx.AsyncClient(
timeout=self.timeout,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
)
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
if self._async_client is None:
raise RuntimeError("Reranker not initialized. Call initialize() first.")
if not pairs:
return []
query_groups: dict[str, list[tuple[int, str]]] = {}
for idx, (query, text) in enumerate(pairs):
query_groups.setdefault(query, []).append((idx, text))
all_scores = [0.0] * len(pairs)
for query, indexed_texts in query_groups.items():
texts = [text for _, text in indexed_texts]
indices = [idx for idx, _ in indexed_texts]
body: dict[str, object] = {
"model": self.model,
"query": query,
"documents": texts,
"return_documents": False,
}
if self.include_top_n:
body["top_n"] = len(texts)
response = await self._async_client.post(self.rerank_url, json=body)
response.raise_for_status()
result = response.json()
for item in result.get("results", []):
original_idx = item["index"]
score = item["relevance_score"]
all_scores[indices[original_idx]] = score
return all_scores
class CohereCrossEncoder(CrossEncoderModel):
"""
Cohere cross-encoder implementation using the Cohere Rerank API.
@@ -546,7 +627,20 @@ class CohereCrossEncoder(CrossEncoderModel):
self.base_url = base_url
self.timeout = timeout
self._client = None
self._httpx_client: httpx.Client | None = None
# Used when base_url is set (Azure AI Foundry and other Cohere-compatible hosts).
# Azure endpoints already include the full invoke path, so rerank_url == base_url
# and top_n is omitted to match the existing Azure contract.
self._http_client: _CohereCompatibleRerankClient | None = (
_CohereCompatibleRerankClient(
api_key=api_key,
model=model,
rerank_url=base_url,
timeout=timeout,
include_top_n=False,
)
if base_url
else None
)
@property
def provider_name(self) -> str:
@@ -554,23 +648,15 @@ class CohereCrossEncoder(CrossEncoderModel):
async def initialize(self) -> None:
"""Initialize the Cohere client."""
if self._client is not None or self._httpx_client is not None:
if self._client is not None or (self._http_client and self._http_client._async_client):
return
base_url_msg = f" at {self.base_url}" if self.base_url else ""
logger.info(f"Reranker: initializing Cohere provider with model {self.model}{base_url_msg}")
if self.base_url:
# For custom endpoints (Azure AI Foundry), use httpx directly to avoid SDK path appending
# Azure endpoints already include the full path (e.g., /models/.../invoke)
self._httpx_client = httpx.Client(
timeout=self.timeout,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
)
logger.info("Reranker: Cohere provider initialized (using httpx for custom endpoint)")
if self._http_client is not None:
await self._http_client.initialize()
logger.info("Reranker: Cohere provider initialized (Cohere-compatible HTTP endpoint)")
else:
# For native Cohere API, use the official SDK
try:
@@ -591,25 +677,24 @@ class CohereCrossEncoder(CrossEncoderModel):
Returns:
List of relevance scores
"""
if self._client is None and self._httpx_client is None:
if self._client is None and self._http_client is None:
raise RuntimeError("Reranker not initialized. Call initialize() first.")
if not pairs:
return []
# Run sync Cohere API calls in thread pool
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._predict_sync, pairs)
if self._http_client is not None:
return await self._http_client.predict(pairs)
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Synchronous predict implementation for Cohere API."""
# Group pairs by query for efficient batching
# Cohere rerank expects one query with multiple documents
# Run sync Cohere SDK calls in thread pool
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._predict_sync_sdk, pairs)
def _predict_sync_sdk(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Synchronous predict using the native Cohere SDK."""
query_groups: dict[str, list[tuple[int, str]]] = {}
for idx, (query, text) in enumerate(pairs):
if query not in query_groups:
query_groups[query] = []
query_groups[query].append((idx, text))
query_groups.setdefault(query, []).append((idx, text))
all_scores = [0.0] * len(pairs)
@@ -617,40 +702,17 @@ class CohereCrossEncoder(CrossEncoderModel):
texts = [text for _, text in indexed_texts]
indices = [idx for idx, _ in indexed_texts]
if self._httpx_client:
# Direct HTTP request for custom endpoints (Azure AI Foundry)
response = self._httpx_client.post(
self.base_url,
json={
"model": self.model,
"query": query,
"documents": texts,
"return_documents": False,
},
)
response.raise_for_status()
result = response.json()
response = self._client.rerank(
query=query,
documents=texts,
model=self.model,
return_documents=False,
)
# Map scores back to original positions
# Azure Cohere response format: {"results": [{"index": 0, "relevance_score": 0.9}, ...]}
for item in result.get("results", []):
original_idx = item["index"]
score = item["relevance_score"]
all_scores[indices[original_idx]] = score
else:
# Native Cohere SDK for standard API
response = self._client.rerank(
query=query,
documents=texts,
model=self.model,
return_documents=False,
)
# Map scores back to original positions
for result in response.results:
original_idx = result.index
score = result.relevance_score
all_scores[indices[original_idx]] = score
for result in response.results:
original_idx = result.index
score = result.relevance_score
all_scores[indices[original_idx]] = score
return all_scores
@@ -673,89 +735,70 @@ class ZeroEntropyCrossEncoder(CrossEncoderModel):
base_url: str | None = None,
timeout: float = 60.0,
):
"""
Initialize ZeroEntropy cross-encoder client.
Args:
api_key: ZeroEntropy API key
model: ZeroEntropy rerank model name (default: zerank-2)
base_url: Custom base URL for ZeroEntropy-compatible API (e.g., mock server or proxy)
timeout: Request timeout in seconds (default: 60.0)
"""
self.api_key = api_key
self.model = model
self.base_url = base_url.rstrip("/") if base_url else self.DEFAULT_BASE_URL
self.rerank_url = f"{self.base_url}{self.RERANK_PATH}"
self.timeout = timeout
self._async_client: httpx.AsyncClient | None = None
self._client = _CohereCompatibleRerankClient(
api_key=api_key,
model=model,
rerank_url=f"{self.base_url}{self.RERANK_PATH}",
timeout=timeout,
)
@property
def provider_name(self) -> str:
return "zeroentropy"
async def initialize(self) -> None:
"""Initialize the async HTTP client."""
if self._async_client is not None:
if self._client._async_client is not None:
return
logger.info(f"Reranker: initializing ZeroEntropy provider with model {self.model}")
self._async_client = httpx.AsyncClient(
timeout=self.timeout,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
)
await self._client.initialize()
logger.info("Reranker: ZeroEntropy provider initialized")
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
Score query-document pairs using the ZeroEntropy Rerank API.
return await self._client.predict(pairs)
Args:
pairs: List of (query, document) tuples to score
Returns:
List of relevance scores
"""
if self._async_client is None:
raise RuntimeError("Reranker not initialized. Call initialize() first.")
class SiliconFlowCrossEncoder(CrossEncoderModel):
"""
SiliconFlow cross-encoder implementation.
if not pairs:
return []
SiliconFlow (https://siliconflow.cn) exposes a Cohere-compatible /rerank
endpoint. Shares the HTTP client with ZeroEntropy/Cohere-custom-endpoint
via _CohereCompatibleRerankClient.
"""
# Group pairs by query for efficient batching
query_groups: dict[str, list[tuple[int, str]]] = {}
for idx, (query, text) in enumerate(pairs):
if query not in query_groups:
query_groups[query] = []
query_groups[query].append((idx, text))
RERANK_PATH = "/rerank"
all_scores = [0.0] * len(pairs)
def __init__(
self,
api_key: str,
model: str = DEFAULT_RERANKER_SILICONFLOW_MODEL,
base_url: str = DEFAULT_RERANKER_SILICONFLOW_BASE_URL,
timeout: float = 60.0,
):
self.model = model
self.base_url = base_url.rstrip("/")
self._client = _CohereCompatibleRerankClient(
api_key=api_key,
model=model,
rerank_url=f"{self.base_url}{self.RERANK_PATH}",
timeout=timeout,
)
for query, indexed_texts in query_groups.items():
texts = [text for _, text in indexed_texts]
indices = [idx for idx, _ in indexed_texts]
@property
def provider_name(self) -> str:
return "siliconflow"
response = await self._async_client.post(
self.rerank_url,
json={
"model": self.model,
"query": query,
"documents": texts,
"top_n": len(texts),
},
)
response.raise_for_status()
result = response.json()
async def initialize(self) -> None:
if self._client._async_client is not None:
return
logger.info(f"Reranker: initializing SiliconFlow provider at {self.base_url} with model {self.model}")
await self._client.initialize()
logger.info("Reranker: SiliconFlow provider initialized")
# Map scores back to original positions
for item in result.get("results", []):
original_idx = item["index"]
score = item["relevance_score"]
all_scores[indices[original_idx]] = score
return all_scores
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
return await self._client.predict(pairs)
class RRFPassthroughCrossEncoder(CrossEncoderModel):
@@ -1207,14 +1250,31 @@ class JinaMLXCrossEncoder(CrossEncoderModel):
if self._reranker is not None:
return
# Pre-warm transformers.AutoTokenizer to fully populate the transformers
# namespace before mlx_lm imports it. transformers 5.x uses _LazyModule,
# which has an unguarded window where `from transformers import AutoTokenizer`
# raises ImportError if another thread is concurrently initializing the
# namespace (e.g. embeddings init in an executor thread).
# See: https://github.com/vectorize-io/hindsight/issues/994
import transformers
_ = transformers.AutoTokenizer
try:
import mlx.core # noqa: F401
import mlx_lm # noqa: F401
except ImportError:
except ImportError as exc:
# Only swallow "package not installed" errors. Anything else (e.g. a
# transitive import failure inside mlx_lm) must surface verbatim so
# the real cause is debuggable instead of being masked by a generic
# "install mlx" message.
msg = str(exc)
if "mlx" not in msg and "mlx_lm" not in msg:
raise
raise ImportError(
"mlx and mlx-lm are required for JinaMLXCrossEncoder. "
"Install with: pip install mlx>=0.31.0 mlx-lm>=0.31.1 safetensors>=0.6.2"
)
) from exc
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._load_model)
@@ -1513,6 +1573,17 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
api_key=api_key,
model=config.reranker_zeroentropy_model,
)
elif provider == "siliconflow":
api_key = config.reranker_siliconflow_api_key
if not api_key:
raise ValueError(
f"{ENV_RERANKER_SILICONFLOW_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'siliconflow'"
)
return SiliconFlowCrossEncoder(
api_key=api_key,
model=config.reranker_siliconflow_model,
base_url=config.reranker_siliconflow_base_url,
)
elif provider == "google":
project_id = config.reranker_google_project_id
if not project_id:
@@ -1531,5 +1602,5 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
return JinaMLXCrossEncoder()
else:
raise ValueError(
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'zeroentropy', 'google', 'flashrank', 'litellm', 'litellm-sdk', 'rrf', 'jina-mlx'"
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'zeroentropy', 'siliconflow', 'google', 'flashrank', 'litellm', 'litellm-sdk', 'rrf', 'jina-mlx'"
)
@@ -536,6 +536,15 @@ class LLMProvider:
OutputTooLongError: If output exceeds token limits.
Exception: Re-raises API errors after retries exhausted.
"""
# Stage breadcrumb so the worker log shows which LLM call a task is
# currently inside; the stage_age field then reveals long JSON-schema
# retry loops (e.g. a small model that can't satisfy strict_schema).
# No-op outside a worker context.
from ..worker.stage import set_stage
structured = "+structured" if response_format is not None else ""
set_stage(f"llm.{self.provider}.{scope}{structured}")
async with _global_llm_semaphore:
# Delegate to provider implementation
result = await self._provider_impl.call(
@@ -592,6 +601,10 @@ class LLMProvider:
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
from ..worker.stage import set_stage
set_stage(f"llm.{self.provider}.{scope}+tools")
async with _global_llm_semaphore:
# Delegate to provider implementation
result = await self._provider_impl.call_with_tools(
@@ -24,11 +24,18 @@ import asyncpg
import httpx
import tiktoken
from ..config import DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS, get_config
from ..config import (
DEFAULT_RECALL_CHUNKS_MAX_TOKENS,
DEFAULT_RECALL_INCLUDE_CHUNKS,
DEFAULT_RECALL_MAX_TOKENS,
DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS,
get_config,
)
from ..metrics import get_metrics_collector
from ..tracing import create_operation_span
from ..utils import mask_network_location
from ..worker.exceptions import RetryTaskAt
from ..worker.stage import set_stage
from .audit import AuditLogger, audit_context
from .db_budget import budgeted_operation
from .operation_metadata import (
@@ -951,6 +958,9 @@ class MemoryEngine(MemoryEngineInterface):
fact_types = trigger_data.get("fact_types")
exclude_mental_models = trigger_data.get("exclude_mental_models", False)
stored_exclude_ids: list[str] = trigger_data.get("exclude_mental_model_ids") or []
recall_include_chunks_override = trigger_data.get("include_chunks")
recall_max_tokens_override = trigger_data.get("recall_max_tokens")
recall_chunks_max_tokens_override = trigger_data.get("recall_chunks_max_tokens")
tag_filtering = _resolve_refresh_tag_filtering(mental_model.get("tags"), trigger_data)
@@ -966,6 +976,9 @@ class MemoryEngine(MemoryEngineInterface):
fact_types=fact_types,
exclude_mental_models=exclude_mental_models,
exclude_mental_model_ids=list({*stored_exclude_ids, mental_model_id}),
recall_include_chunks=recall_include_chunks_override,
recall_max_tokens_override=recall_max_tokens_override,
recall_chunks_max_tokens_override=recall_chunks_max_tokens_override,
)
generated_content = reflect_result.text or "No content generated"
@@ -1090,6 +1103,9 @@ class MemoryEngine(MemoryEngineInterface):
self._audit_logger, task_type or "unknown", "system", bank_id, request=task_dict
) as audit_entry:
try:
# Stage breadcrumb for the worker poller's WORKER_TASK log line.
# No-op outside a worker context.
set_stage(f"task.{task_type}")
if task_type == "batch_retain":
await self._handle_batch_retain(task_dict)
elif task_type == "file_convert_retain":
@@ -1140,6 +1156,26 @@ class MemoryEngine(MemoryEngineInterface):
logger.error(f"Not retrying task {task_type} (non-retryable), marking as failed")
if operation_id:
await self._mark_operation_failed(operation_id, str(e), error_traceback)
elif isinstance(e, asyncpg.exceptions.IntegrityConstraintViolationError):
# Non-retryable: deterministic Postgres integrity violations
# (UniqueViolationError, ForeignKeyViolationError, CheckViolationError,
# NotNullViolationError, ExclusionViolationError) will never succeed on
# retry — the offending row state is already committed. Retrying just
# burns worker capacity. See vectorize-io/hindsight#980.
logger.error(
f"Not retrying task {task_type} (integrity violation, deterministic): {type(e).__name__}"
)
if task_type == "consolidation" and operation_id:
await self._fire_consolidation_webhook(
bank_id=task_dict.get("bank_id", ""),
operation_id=operation_id,
status="failed",
result=None,
error_message=str(e),
schema=schema,
)
if operation_id:
await self._mark_operation_failed(operation_id, str(e), error_traceback)
else:
if task_type == "consolidation" and operation_id:
# Fire failure webhook (non-transactional — operation not yet marked failed;
@@ -2743,8 +2779,11 @@ class MemoryEngine(MemoryEngineInterface):
pool = await self._get_pool()
recall_start = time.time()
# Buffer logs for clean output in concurrent scenarios
recall_id = f"{bank_id[:8]}-{int(time.time() * 1000) % 100000}"
# Buffer logs for clean output in concurrent scenarios.
# Include a uuid suffix so two recalls on the same bank within the
# same millisecond don't collide on the budgeted_operation key
# (`recall-{recall_id}`), which would raise "Operation ... already exists".
recall_id = f"{bank_id[:8]}-{int(time.time() * 1000) % 100000}-{uuid.uuid4().hex[:6]}"
log_buffer = []
tags_info = f", tags={tags}, tags_match={tags_match}" if tags else ""
log_buffer.append(
@@ -2765,7 +2804,8 @@ class MemoryEngine(MemoryEngineInterface):
embedding_span.set_attribute("hindsight.query", query[:100])
try:
query_embedding = embedding_utils.generate_embedding(self.embeddings, query)
query_embeddings = await embedding_utils.generate_embeddings_batch(self.embeddings, [query])
query_embedding = query_embeddings[0]
step_duration = time.time() - step_start
log_buffer.append(f" [1] Generate query embedding: {step_duration:.3f}s")
finally:
@@ -3086,8 +3126,13 @@ class MemoryEngine(MemoryEngineInterface):
# Step 4.5: Combine cross-encoder score with retrieval signals via multiplicative boosts.
# See apply_combined_scoring for the full rationale and formula.
# is_passthrough_reranker tells the scoring code to seed CE scores
# from RRF rank — only meaningful when the configured reranker is
# the slim/passthrough one that returns a constant score per pair.
if scored_results:
apply_combined_scoring(scored_results, now=utcnow())
ce = reranker_instance.cross_encoder
is_passthrough = ce is not None and ce.provider_name == "rrf"
apply_combined_scoring(scored_results, now=utcnow(), is_passthrough_reranker=is_passthrough)
scored_results.sort(key=lambda x: x.weight, reverse=True)
log_buffer.append(" [4.6] Combined scoring: ce * recency_boost(0.2) * temporal_boost(0.2)")
@@ -5366,6 +5411,9 @@ class MemoryEngine(MemoryEngineInterface):
exclude_mental_model_ids: list[str] | None = None,
fact_types: list[str] | None = None,
exclude_mental_models: bool = False,
recall_include_chunks: bool | None = None,
recall_max_tokens_override: int | None = None,
recall_chunks_max_tokens_override: int | None = None,
_skip_span: bool = False,
) -> ReflectResult:
"""
@@ -5488,6 +5536,23 @@ class MemoryEngine(MemoryEngineInterface):
"reflect_source_facts_max_tokens", DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS
)
# Resolve recall overrides: caller arg (e.g. mental model trigger) → bank config → env default
effective_recall_include_chunks = (
recall_include_chunks
if recall_include_chunks is not None
else config_dict.get("recall_include_chunks", DEFAULT_RECALL_INCLUDE_CHUNKS)
)
effective_recall_max_tokens = (
recall_max_tokens_override
if recall_max_tokens_override is not None
else config_dict.get("recall_max_tokens", DEFAULT_RECALL_MAX_TOKENS)
)
effective_recall_chunks_max_tokens = (
recall_chunks_max_tokens_override
if recall_chunks_max_tokens_override is not None
else config_dict.get("recall_chunks_max_tokens", DEFAULT_RECALL_CHUNKS_MAX_TOKENS)
)
async def search_observations_fn(q: str, max_tokens: int = 5000) -> dict[str, Any]:
return await tool_search_observations(
self,
@@ -5508,7 +5573,14 @@ class MemoryEngine(MemoryEngineInterface):
recall_fact_types = [ft for ft in (fact_types or ["world", "experience"]) if ft in ("world", "experience")]
include_recall = bool(recall_fact_types)
async def recall_fn(q: str, max_tokens: int = 4096, max_chunk_tokens: int = 1000) -> dict[str, Any]:
# Defaults are bound at closure-definition time (re-evaluated on each
# reflect_async call), so per-bank/per-trigger overrides apply when the
# agent invokes recall without explicit token args.
async def recall_fn(
q: str,
max_tokens: int = effective_recall_max_tokens,
max_chunk_tokens: int = effective_recall_chunks_max_tokens,
) -> dict[str, Any]:
return await tool_recall(
self,
bank_id,
@@ -5520,6 +5592,7 @@ class MemoryEngine(MemoryEngineInterface):
tag_groups=tag_groups,
max_chunk_tokens=max_chunk_tokens,
fact_types=recall_fact_types if fact_types is not None else None,
include_chunks=effective_recall_include_chunks,
)
async def expand_fn(memory_ids: list[str], depth: str) -> dict[str, Any]:
@@ -6737,6 +6810,9 @@ class MemoryEngine(MemoryEngineInterface):
fact_types = trigger_data.get("fact_types")
exclude_mental_models = trigger_data.get("exclude_mental_models", False)
stored_exclude_ids: list[str] = trigger_data.get("exclude_mental_model_ids") or []
recall_include_chunks_override = trigger_data.get("include_chunks")
recall_max_tokens_override = trigger_data.get("recall_max_tokens")
recall_chunks_max_tokens_override = trigger_data.get("recall_chunks_max_tokens")
tag_filtering = _resolve_refresh_tag_filtering(mental_model.get("tags"), trigger_data)
@@ -6752,6 +6828,9 @@ class MemoryEngine(MemoryEngineInterface):
fact_types=fact_types,
exclude_mental_models=exclude_mental_models,
exclude_mental_model_ids=list({*stored_exclude_ids, mental_model_id}),
recall_include_chunks=recall_include_chunks_override,
recall_max_tokens_override=recall_max_tokens_override,
recall_chunks_max_tokens_override=recall_chunks_max_tokens_override,
_skip_span=True,
)
@@ -7400,6 +7479,7 @@ class MemoryEngine(MemoryEngineInterface):
operation_id: str,
*,
request_context: "RequestContext",
include_payload: bool = False,
) -> dict[str, Any]:
"""Get the status of a specific async operation.
@@ -7422,9 +7502,10 @@ class MemoryEngine(MemoryEngineInterface):
op_uuid = uuid.UUID(operation_id)
async with acquire_with_retry(pool) as conn:
payload_column = ", task_payload" if include_payload else ""
row = await conn.fetchrow(
f"""
SELECT operation_id, operation_type, created_at, updated_at, completed_at, status, error_message, result_metadata
SELECT operation_id, operation_type, created_at, updated_at, completed_at, status, error_message, result_metadata{payload_column}
FROM {fq_table("async_operations")}
WHERE operation_id = $1 AND bank_id = $2
""",
@@ -7436,6 +7517,7 @@ class MemoryEngine(MemoryEngineInterface):
# Check if this is a parent operation
result_metadata = json.loads(row["result_metadata"]) if row["result_metadata"] else {}
is_parent = result_metadata.get("is_parent", False)
task_payload = json.loads(row["task_payload"]) if include_payload and row["task_payload"] else None
# Use status from database (parent status is updated when all children complete/fail)
db_status = row["status"]
@@ -7512,6 +7594,7 @@ class MemoryEngine(MemoryEngineInterface):
"error_message": row["error_message"],
"result_metadata": result_metadata,
"child_operations": child_statuses,
"task_payload": task_payload,
}
else:
# Regular operation (not a parent)
@@ -7524,6 +7607,7 @@ class MemoryEngine(MemoryEngineInterface):
"completed_at": row["completed_at"].isoformat() if row["completed_at"] else None,
"error_message": row["error_message"],
"result_metadata": result_metadata,
"task_payload": task_payload,
}
else:
# Operation not found
@@ -23,6 +23,7 @@ from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.llm_wrapper import parse_llm_json
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
from hindsight_api.worker.stage import set_stage
logger = logging.getLogger(__name__)
@@ -242,6 +243,8 @@ class GeminiLLM(LLMInterface):
last_exception = None
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.gemini.{scope}.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await asyncio.wait_for(
self._client.aio.models.generate_content(
@@ -527,6 +530,8 @@ class GeminiLLM(LLMInterface):
last_exception = None
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.gemini.tools.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await asyncio.wait_for(
self._client.aio.models.generate_content(
@@ -21,6 +21,7 @@ from typing import Any
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
from hindsight_api.worker.stage import set_stage
logger = logging.getLogger(__name__)
@@ -141,6 +142,8 @@ class LiteLLMLLM(LLMInterface):
last_exception = None
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.litellm.{scope}.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await self._litellm.acompletion(**call_kwargs)
@@ -283,6 +286,8 @@ class LiteLLMLLM(LLMInterface):
last_exception = None
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.litellm.tools.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await self._litellm.acompletion(**call_kwargs)
@@ -33,6 +33,7 @@ from hindsight_api.config import DEFAULT_LLM_TIMEOUT, ENV_LLM_TIMEOUT
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
from hindsight_api.worker.stage import set_stage
logger = logging.getLogger(__name__)
@@ -196,16 +197,29 @@ class OpenAICompatibleLLM(LLMInterface):
def _max_tokens_param_name(self) -> str:
"""Return the correct parameter name for limiting response tokens.
Native OpenAI and Groq accept 'max_completion_tokens'. Mistral and other
OpenAI-compatible endpoints that haven't adopted the newer parameter name
require 'max_tokens'. Using a custom base_url with the openai provider
signals a third-party compatible API, so fall back to 'max_tokens'.
Native OpenAI, Azure OpenAI, Groq, and llamacpp accept 'max_completion_tokens'.
Mistral and other OpenAI-compatible endpoints that haven't adopted the newer
parameter name require 'max_tokens', so when the openai provider is configured
with a non-Azure custom base_url we fall back to the widely-supported
'max_tokens'.
Reasoning models (GPT-5, o1, o3) only accept 'max_completion_tokens' and reject
'max_tokens' outright, so they always use the new parameter name regardless of
base_url.
"""
# Reasoning models (GPT-5, o1, o3, ...) only accept max_completion_tokens.
# Azure OpenAI + GPT-5 is the canonical example: issue #978.
if self._supports_reasoning_model():
return "max_completion_tokens"
# Native OpenAI (no custom base URL), Groq, and llamacpp use max_completion_tokens
if self.provider in ("groq", "llamacpp"):
return "max_completion_tokens"
if self.provider == "openai" and not self.base_url:
return "max_completion_tokens"
# Azure OpenAI is fully OpenAI-API-compatible — detect it by hostname so users
# can keep provider=openai + an Azure base_url (the documented setup).
if self.provider == "openai" and self.base_url and ".openai.azure.com" in self.base_url:
return "max_completion_tokens"
# openai with custom base_url, ollama, lmstudio, minimax, volcano —
# use the widely-supported max_tokens
return "max_tokens"
@@ -349,6 +363,11 @@ class OpenAICompatibleLLM(LLMInterface):
last_exception = None
for attempt in range(max_retries + 1):
# Surface attempt count in worker stage so JSON-schema retry loops
# are visible from logs (small models on strict structured output
# often loop here). Cheap no-op outside worker context.
if attempt > 0:
set_stage(f"llm.{self.provider}.{scope}.attempt={attempt + 1}/{max_retries + 1}")
try:
if response_format is not None:
response = await self._client.chat.completions.create(**call_params)
@@ -616,6 +635,8 @@ class OpenAICompatibleLLM(LLMInterface):
last_exception = None
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.{self.provider}.tools.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await self._client.chat.completions.create(**call_params)
@@ -765,6 +786,8 @@ class OpenAICompatibleLLM(LLMInterface):
async with httpx.AsyncClient(timeout=300.0) as client:
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.ollama_native.{scope}.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await client.post(native_url, json=payload)
response.raise_for_status()
@@ -214,6 +214,7 @@ async def tool_recall(
connection_budget: int = 1,
max_chunk_tokens: int = 1000,
fact_types: list[str] | None = None,
include_chunks: bool = True,
) -> dict[str, Any]:
"""
Search memories using TEMPR retrieval.
@@ -230,15 +231,15 @@ async def tool_recall(
tags: Filter by tags (includes untagged memories)
tags_match: How to match tags - "any" (OR), "all" (AND), or "exact"
connection_budget: Max DB connections for this recall (default 1 for internal ops)
max_chunk_tokens: Maximum tokens for raw source chunk text (default 1000, always included)
max_chunk_tokens: Maximum tokens for raw source chunk text (default 1000)
fact_types: Optional filter for fact types to retrieve. Defaults to ["experience", "world"].
include_chunks: Whether to fetch raw chunk text alongside facts (default True).
Returns:
Dict with list of matching memories including raw chunk text
Dict with list of matching memories including raw chunk text (when include_chunks)
"""
# Only world/experience are valid for raw recall (observation is handled by search_observations)
recall_fact_type = [ft for ft in (fact_types or ["experience", "world"]) if ft in ("world", "experience")]
include_chunks = True
internal_ctx = replace(request_context, internal=True)
result = await memory_engine.recall_async(
bank_id=bank_id,
@@ -100,11 +100,20 @@ async def store_chunks_batch(conn, bank_id: str, document_id: str, chunks: list[
content_hashes.append(compute_chunk_hash(chunk.chunk_text))
chunk_id_map[chunk.chunk_index] = chunk_id
# Batch insert all chunks
# Batch upsert all chunks. ON CONFLICT makes this idempotent: re-submitting
# a retain under the same document_id (the pattern in vectorize-io/hindsight#977)
# may produce chunk_ids that already exist when upstream cascade-delete or
# delta-retain paths don't run (or race with a concurrent task). Overwriting
# is the correct behavior per the document_id grouping semantics — the caller
# intends this chunk to hold the latest content at that (document_id, index).
await conn.execute(
f"""
INSERT INTO {fq_table("chunks")} (chunk_id, document_id, bank_id, chunk_text, chunk_index, content_hash)
SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::text[], $5::integer[], $6::text[])
ON CONFLICT (chunk_id) DO UPDATE SET
chunk_text = EXCLUDED.chunk_text,
chunk_index = EXCLUDED.chunk_index,
content_hash = EXCLUDED.content_hash
""",
chunk_ids,
[document_id] * len(chunk_texts),
@@ -47,6 +47,16 @@ async def generate_embeddings_batch(embeddings_backend, texts: list[str]) -> lis
embeddings_backend.encode,
texts,
)
return embeddings
except Exception as e:
raise Exception(f"Failed to generate batch embeddings: {str(e)}")
# Guarantee 1:1 alignment with input texts. A silent length mismatch here
# propagates downstream as zip() drops items, eventually surfacing as an
# IndexError in retain mapping (see issue #1037).
if len(embeddings) != len(texts):
raise RuntimeError(
f"Embeddings backend returned {len(embeddings)} vectors for {len(texts)} input texts; "
"expected exact 1:1 alignment"
)
return embeddings
@@ -776,17 +776,13 @@ async def compute_semantic_links_ann(
await conn.execute("SET LOCAL hnsw.ef_search = 60")
t_setup = time_mod.time()
await conn.execute(
"CREATE TEMP TABLE _ann_seeds (unit_id text, emb_text text, fact_type text) ON COMMIT DROP"
)
await conn.execute("CREATE TEMP TABLE _ann_seeds (unit_id text, emb_text text, fact_type text) ON COMMIT DROP")
records = [
(uid, emb if isinstance(emb, str) else str(emb), ft)
for uid, emb, ft in zip(unit_ids, embeddings, fact_types)
]
await conn.copy_records_to_table(
"_ann_seeds", records=records, columns=["unit_id", "emb_text", "fact_type"]
)
await conn.copy_records_to_table("_ann_seeds", records=records, columns=["unit_id", "emb_text", "fact_type"])
logger.debug(f"[ANN] Temp table setup: {time_mod.time() - t_setup:.3f}s ({len(records)} seeds)")
# Run one ANN query per fact_type so each uses the right HNSW index.
@@ -14,6 +14,7 @@ from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from typing import Any
from ...worker.stage import set_stage
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from . import bank_utils
@@ -71,7 +72,6 @@ from . import (
from .types import (
ChunkMetadata,
EntityResolutionResult,
ExtractedFact,
Phase1Result,
Phase3Context,
ProcessedFact,
@@ -133,6 +133,7 @@ async def _pre_resolve_phase1(
Running these outside the transaction avoids holding row locks during
slow reads, eliminating TimeoutErrors under concurrent load.
"""
set_stage("retain.phase1.resolve")
from .link_utils import compute_semantic_links_ann
user_entities_per_content = {idx: content.entities for idx, content in enumerate(contents) if content.entities}
@@ -238,6 +239,7 @@ async def _insert_facts_and_links(
only the unit_entities INSERT (FK to memory_units) stays in the transaction.
Entity link building is deferred to Phase 3 (post-transaction, best-effort).
"""
set_stage("retain.phase2.insert_facts")
unit_ids = await fact_storage.insert_facts_batch(conn, bank_id, processed_facts)
step_start = time.time()
log_buffer.append(f" Insert facts: {len(unit_ids)} units in {time.time() - step_start:.3f}s")
@@ -299,8 +301,11 @@ async def _insert_facts_and_links(
causal_link_count = await link_creation.create_causal_links_batch(conn, bank_id, unit_ids, processed_facts)
log_buffer.append(f" Causal links: {causal_link_count} links in {time.time() - step_start:.3f}s")
# Map results back to original content items
result_unit_ids = _map_results_to_contents(contents, extracted_facts, unit_ids if unit_ids else [])
# Map results back to original content items. Use processed_facts (not
# extracted_facts) because unit_ids has 1:1 alignment with processed_facts —
# any upstream drop between extraction and processing would otherwise cause
# an IndexError (see issue #1037).
result_unit_ids = _map_results_to_contents(contents, processed_facts, unit_ids if unit_ids else [])
if outbox_callback:
await outbox_callback(conn)
@@ -322,6 +327,7 @@ async def _build_and_insert_entity_links_phase3(
Entity links are for UI graph visualization only — retrieval uses
the unit_entities self-join instead.
"""
set_stage("retain.phase3.entity_links")
p3_unit_ids = phase3_ctx.unit_ids
p3_resolved = phase3_ctx.resolved_entity_ids
p3_entity_to_unit = phase3_ctx.entity_to_unit
@@ -367,6 +373,7 @@ async def _extract_and_embed(
Returns:
Tuple of (extracted_facts, processed_facts, chunks_metadata, usage)
"""
set_stage("retain.extract_and_embed")
step_start = time.time()
extracted_facts, chunks, usage = await fact_extraction.extract_facts_from_contents(
contents, llm_config, agent_name, config, pool, operation_id, schema
@@ -482,8 +489,8 @@ async def retain_batch(
return result_unit_ids, total_usage
# Resolve effective document_id early so both delta and streaming paths
# can find existing chunks from a prior attempt. On retry, the generated
# document_id is recovered from operation result_metadata.
# can find existing chunks from a prior attempt. On retry, a generated
# document_id is recovered from operation result_metadata.document_ids[0].
effective_doc_id = document_id
if not effective_doc_id:
doc_ids = {item.get("document_id") for item in contents_dicts if item.get("document_id")}
@@ -502,26 +509,41 @@ async def retain_batch(
if isinstance(row["result_metadata"], dict)
else json.loads(row["result_metadata"])
)
effective_doc_id = meta.get("generated_document_id")
recovered = meta.get("document_ids") or []
if recovered:
effective_doc_id = recovered[0]
except Exception:
pass
if not effective_doc_id:
effective_doc_id = str(uuid.uuid4())
# Persist so retries reuse the same document_id
if operation_id:
try:
async with acquire_with_retry(pool) as conn:
await conn.execute(
f"""
UPDATE {fq_table("async_operations")}
SET result_metadata = result_metadata || $1::jsonb, updated_at = now()
WHERE operation_id = $2
""",
json.dumps({"generated_document_id": effective_doc_id}),
uuid.UUID(operation_id),
)
except Exception:
logger.warning("Failed to persist generated document_id", exc_info=True)
# Record effective_doc_id on the operation (idempotent set-append). Captures
# both user-provided and generated ids so the operation shows every document
# it touched, and lets retries reuse the same generated id.
if operation_id:
try:
async with acquire_with_retry(pool) as conn:
await conn.execute(
f"""
UPDATE {fq_table("async_operations")}
SET result_metadata = jsonb_set(
COALESCE(result_metadata, '{{}}'::jsonb),
'{{document_ids}}',
CASE
WHEN COALESCE(result_metadata->'document_ids', '[]'::jsonb) @> $1::jsonb
THEN result_metadata->'document_ids'
ELSE COALESCE(result_metadata->'document_ids', '[]'::jsonb) || $1::jsonb
END,
true
),
updated_at = now()
WHERE operation_id = $2
""",
json.dumps([effective_doc_id]),
uuid.UUID(operation_id),
)
except Exception:
logger.warning("Failed to persist document_id", exc_info=True)
# --- Append mode: prepend existing document content to new content ---
# When update_mode="append", fetch the existing document text and prepend it
@@ -1545,12 +1567,19 @@ def _build_delta_contents(
def _map_results_to_contents(
contents: list[RetainContent],
extracted_facts: list[ExtractedFact],
processed_facts: list[ProcessedFact],
unit_ids: list[str],
) -> list[list[str]]:
"""Map created unit IDs back to original content items."""
"""Map created unit IDs back to original content items.
`processed_facts` and `unit_ids` must have the same length: each unit_id
corresponds to the processed_fact at the same index.
"""
if len(processed_facts) != len(unit_ids):
raise ValueError(f"processed_facts ({len(processed_facts)}) and unit_ids ({len(unit_ids)}) length mismatch")
facts_by_content: dict[int, list[int]] = {i: [] for i in range(len(contents))}
for i, fact in enumerate(extracted_facts):
for i, fact in enumerate(processed_facts):
# Normalize content_index: some LLM providers return 1-indexed values.
# Clamp to valid range to prevent KeyError.
idx = fact.content_index
@@ -1559,12 +1588,8 @@ def _map_results_to_contents(
facts_by_content[idx].append(i)
result_unit_ids = []
unit_idx = 0
for content_index in range(len(contents)):
content_unit_ids = []
for _ in facts_by_content[content_index]:
content_unit_ids.append(unit_ids[unit_idx])
unit_idx += 1
content_unit_ids = [unit_ids[i] for i in facts_by_content[content_index]]
result_unit_ids.append(content_unit_ids)
return result_unit_ids
@@ -23,6 +23,7 @@ def apply_combined_scoring(
recency_alpha: float = _RECENCY_ALPHA,
temporal_alpha: float = _TEMPORAL_ALPHA,
proof_count_alpha: float = _PROOF_COUNT_ALPHA,
is_passthrough_reranker: bool = False,
) -> None:
"""Apply combined scoring to a list of ScoredResults in-place.
@@ -60,6 +61,42 @@ def apply_combined_scoring(
if now.tzinfo is None:
now = now.replace(tzinfo=UTC)
# When the configured cross-encoder is a passthrough (e.g.
# RRFPassthroughCrossEncoder used by slim deployments), every
# cross_encoder_score_normalized is identical and provides no relevance
# signal. In that case the multiplicative recency / temporal / proof_count
# boosts below become the *only* ranking signal — making the final order a
# pure recency sort regardless of how relevant a candidate actually is.
#
# Detect that case and seed cross_encoder_score_normalized from the RRF
# rank instead, so the boosts modulate a meaningful base score rather than
# replacing it. This is a no-op for real cross-encoders, which produce
# diverse scores.
# When the reranker is a passthrough (e.g. RRFPassthroughCrossEncoder used
# by slim deployments), every cross_encoder_score_normalized is identical
# and provides no relevance signal. The multiplicative recency / temporal /
# proof_count boosts below would then become the *only* ranking signal,
# making the final order a pure recency sort regardless of how relevant a
# candidate actually is.
#
# Seed cross_encoder_score_normalized from the RRF rank instead, so the
# boosts modulate a meaningful base score. Caller passes is_passthrough
# explicitly because "all scores identical" is too fragile a heuristic —
# a real reranker can also tie scores (especially in tests with synthetic
# data) and we'd corrupt legitimate single-result reranks.
if is_passthrough_reranker and scored_results:
n = len(scored_results)
sorted_by_rrf = sorted(
scored_results,
key=lambda s: getattr(getattr(s, "candidate", None), "rrf_score", 0.0),
reverse=True,
)
denom = max(1, n - 1)
for new_rank, sr in enumerate(sorted_by_rrf):
# Map rank → [0.1, 1.0] so the recency boost can still nudge
# ordering between adjacent candidates without overpowering RRF.
sr.cross_encoder_score_normalized = 1.0 - (0.9 * new_rank / denom)
for sr in scored_results:
# Recency: linear decay over 365 days → [0.1, 1.0]; neutral 0.5 if no date.
sr.recency = 0.5
+367 -63
View File
@@ -6,15 +6,17 @@ FOR UPDATE SKIP LOCKED for safe concurrent claiming.
"""
import asyncio
import io
import json
import logging
import time
import traceback
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from .exceptions import RetryTaskAt
from .stage import StageHolder, bind_holder
if TYPE_CHECKING:
import asyncpg
@@ -26,6 +28,31 @@ logger = logging.getLogger(__name__)
# Progress logging interval in seconds
PROGRESS_LOG_INTERVAL = 30
# Stuck-task stack-dump thresholds (seconds). Each task gets one stack dump
# per threshold it crosses (5min, 10min, 20min, 40min, 80min...).
STUCK_STACK_INITIAL_THRESHOLD_S = 300
STUCK_STACK_MAX_THRESHOLD_S = 3600 * 6 # cap doubling at 6h
@dataclass
class ActiveTaskInfo:
"""Tracking info for an in-flight worker task.
Carries everything the periodic stats / stuck-task logger needs
so it can render a useful per-task line without touching the DB.
"""
op_type: str
bank_id: str
schema: str | None
bg_task: "asyncio.Task[Any]"
started_at: float
stage_holder: StageHolder
# Largest stuck-stack threshold (seconds) for which we've already
# dumped a stack trace; used to suppress repeated dumps.
last_stack_dump_threshold: int = 0
task_type: str = ""
def fq_table(table: str, schema: str | None = None) -> str:
"""Get fully-qualified table name with optional schema prefix."""
@@ -99,8 +126,8 @@ class WorkerPoller:
self._in_flight_lock = asyncio.Lock()
self._last_progress_log = 0.0
self._tasks_completed_since_log = 0
# Track active tasks locally: operation_id -> (op_type, bank_id, schema, asyncio.Task)
self._active_tasks: dict[str, tuple[str, str, str | None, asyncio.Task]] = {}
# Track active tasks locally: operation_id -> ActiveTaskInfo
self._active_tasks: dict[str, ActiveTaskInfo] = {}
# Track in-flight tasks by operation type
self._in_flight_by_type: dict[str, int] = {}
@@ -116,17 +143,25 @@ class WorkerPoller:
"""
Calculate available slots for claiming tasks.
Consolidation has a reserved pool of ``consolidation_max_slots`` within
``max_slots``. Non-consolidation tasks may use at most
``max_slots - consolidation_max_slots`` slots, leaving the remainder
always available for consolidation. This prevents consolidation from
being starved when retain throughput continuously saturates the queue.
Returns:
(total_available, consolidation_available) tuple
(non_consolidation_available, consolidation_available) tuple
"""
async with self._in_flight_lock:
total_in_flight = self._in_flight_count
consolidation_in_flight = self._in_flight_by_type.get("consolidation", 0)
total_available = max(0, self._max_slots - total_in_flight)
non_consolidation_in_flight = max(0, total_in_flight - consolidation_in_flight)
non_consolidation_max = max(0, self._max_slots - self._consolidation_max_slots)
non_consolidation_available = max(0, non_consolidation_max - non_consolidation_in_flight)
consolidation_available = max(0, self._consolidation_max_slots - consolidation_in_flight)
return total_available, consolidation_available
return non_consolidation_available, consolidation_available
async def wait_for_active_tasks(self, timeout: float = 10.0) -> bool:
"""
@@ -164,40 +199,40 @@ class WorkerPoller:
Returns:
List of ClaimedTask objects containing operation_id, task_dict, and schema
"""
# Calculate available slots
total_available, consolidation_available = await self._get_available_slots()
# Calculate available slots (independent pools after reservation)
non_consolidation_available, consolidation_available = await self._get_available_slots()
if total_available <= 0:
if non_consolidation_available <= 0 and consolidation_available <= 0:
return []
schemas = await self._get_schemas()
all_tasks: list[ClaimedTask] = []
remaining_total = total_available
remaining_non_consolidation = non_consolidation_available
remaining_consolidation = consolidation_available
for schema in schemas:
if remaining_total <= 0:
if remaining_non_consolidation <= 0 and remaining_consolidation <= 0:
break
tasks = await self._claim_batch_for_schema(schema, remaining_total, remaining_consolidation)
tasks = await self._claim_batch_for_schema(schema, remaining_non_consolidation, remaining_consolidation)
# Update remaining slots based on what was claimed
for task in tasks:
op_type = task.task_dict.get("operation_type", "unknown")
if op_type == "consolidation":
remaining_consolidation -= 1
else:
remaining_non_consolidation -= 1
all_tasks.extend(tasks)
remaining_total -= len(tasks)
return all_tasks
async def _claim_batch_for_schema(
self, schema: str | None, limit: int, consolidation_limit: int
self, schema: str | None, non_consolidation_limit: int, consolidation_limit: int
) -> list[ClaimedTask]:
"""Claim tasks from a specific schema respecting slot limits."""
try:
return await self._claim_batch_for_schema_inner(schema, limit, consolidation_limit)
return await self._claim_batch_for_schema_inner(schema, non_consolidation_limit, consolidation_limit)
except Exception as e:
# Format schema for logging: custom schemas in quotes, None as-is
schema_display = f'"{schema}"' if schema else str(schema)
@@ -205,37 +240,38 @@ class WorkerPoller:
return []
async def _claim_batch_for_schema_inner(
self, schema: str | None, limit: int, consolidation_limit: int
self, schema: str | None, non_consolidation_limit: int, consolidation_limit: int
) -> list[ClaimedTask]:
"""Inner implementation for claiming tasks from a specific schema with slot limits."""
"""Inner implementation for claiming tasks from a specific schema with slot limits.
Non-consolidation and consolidation pools are independent: each is bounded by
its own limit and they do not borrow from each other.
"""
table = fq_table("async_operations", schema)
async with self._pool.acquire() as conn:
async with conn.transaction():
# Strategy: Claim non-consolidation tasks first, then consolidation up to limit
# 1. Claim non-consolidation tasks
non_consolidation_rows = []
if non_consolidation_limit > 0:
non_consolidation_rows = await conn.fetch(
f"""
SELECT operation_id, 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
LIMIT $1
FOR UPDATE SKIP LOCKED
""",
non_consolidation_limit,
)
# 1. Claim non-consolidation tasks (up to limit)
non_consolidation_rows = await conn.fetch(
f"""
SELECT operation_id, 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
LIMIT $1
FOR UPDATE SKIP LOCKED
""",
limit,
)
claimed_count = len(non_consolidation_rows)
remaining_limit = limit - claimed_count
# 2. Claim consolidation tasks (up to consolidation_limit and remaining_limit)
# 2. Claim consolidation tasks from their reserved pool
consolidation_rows = []
if consolidation_limit > 0 and remaining_limit > 0:
if consolidation_limit > 0:
consolidation_rows = await conn.fetch(
f"""
SELECT operation_id, task_payload, retry_count
@@ -254,16 +290,17 @@ class WorkerPoller:
LIMIT $1
FOR UPDATE SKIP LOCKED
""",
min(consolidation_limit, remaining_limit),
consolidation_limit,
)
all_rows = non_consolidation_rows + consolidation_rows
tagged_rows = [(row, False) for row in non_consolidation_rows] + [
(row, True) for row in consolidation_rows
]
if not all_rows:
if not tagged_rows:
return []
# Claim the tasks by updating status and worker_id
operation_ids = [row["operation_id"] for row in all_rows]
operation_ids = [row["operation_id"] for row, _ in tagged_rows]
await conn.execute(
f"""
UPDATE {table}
@@ -274,12 +311,16 @@ class WorkerPoller:
operation_ids,
)
# Parse and return task payloads with schema context
result = []
for row in all_rows:
for row, is_consolidation in tagged_rows:
task_dict = json.loads(row["task_payload"])
task_dict["_retry_count"] = row["retry_count"]
task_dict["_operation_id"] = str(row["operation_id"])
# The DB row knows the operation_type, but the JSON payload may not
# carry it. Inject it so in-flight tracking and slot accounting
# (which key off task_dict["operation_type"]) work correctly.
if is_consolidation:
task_dict["operation_type"] = "consolidation"
result.append(
ClaimedTask(
operation_id=str(row["operation_id"]),
@@ -426,12 +467,27 @@ class WorkerPoller:
operation_type = task.task_dict.get("operation_type", "unknown")
bank_id = task.task_dict.get("bank_id", "unknown")
# Create background task
bg_task = asyncio.create_task(self._execute_task_inner(task))
# Stage holder is updated by engine code via stage.set_stage(); the
# poller reads it during periodic logging to surface what each
# in-flight task is doing.
holder = StageHolder(stage=f"queued.{task_type}")
# Create background task. The holder is passed in and bound to the
# task's own contextvar scope inside _execute_task_inner so engine
# code running under that task sees it via stage.set_stage().
bg_task = asyncio.create_task(self._execute_task_inner(task, holder))
# Track this task as active
async with self._in_flight_lock:
self._active_tasks[task.operation_id] = (task_type, bank_id, task.schema, bg_task)
self._active_tasks[task.operation_id] = ActiveTaskInfo(
op_type=operation_type,
bank_id=bank_id,
schema=task.schema,
bg_task=bg_task,
started_at=time.monotonic(),
stage_holder=holder,
task_type=task_type,
)
self._in_flight_count += 1
self._in_flight_by_type[operation_type] = self._in_flight_by_type.get(operation_type, 0) + 1
@@ -450,7 +506,7 @@ class WorkerPoller:
if self._in_flight_by_type[operation_type] == 0:
del self._in_flight_by_type[operation_type]
async def _execute_task_inner(self, task: ClaimedTask):
async def _execute_task_inner(self, task: ClaimedTask, holder: StageHolder | None = None):
"""Inner task execution with retry/fail handling.
Tasks that want to be retried raise RetryTaskAt; the poller sets next_retry_at
@@ -461,6 +517,14 @@ class WorkerPoller:
task_type = task.task_dict.get("type", "unknown")
bank_id = task.task_dict.get("bank_id", "unknown")
# Bind the stage holder in this task's own contextvar scope so engine
# code running under us can update it via stage.set_stage(). If holder
# is None (legacy / direct invocation), set_stage becomes a no-op.
if holder is not None:
bind_holder(holder)
holder.stage = f"executor.{task_type}"
holder.updated_at = time.monotonic()
try:
schema_info = f", schema={task.schema}" if task.schema else ""
logger.debug(f"Executing task {task.operation_id} (type={task_type}, bank={bank_id}{schema_info})")
@@ -683,7 +747,7 @@ class WorkerPoller:
while asyncio.get_event_loop().time() - start_time < timeout:
async with self._in_flight_lock:
in_flight = self._in_flight_count
active_task_objects = [task_info[3] for task_info in self._active_tasks.values()]
active_task_objects = [info.bg_task for info in self._active_tasks.values()]
if in_flight == 0:
logger.info(f"Worker {self._worker_id} graceful shutdown complete")
@@ -701,12 +765,19 @@ class WorkerPoller:
# Cancel remaining tasks
async with self._in_flight_lock:
for operation_id, (_, _, _, bg_task) in list(self._active_tasks.items()):
if not bg_task.done():
bg_task.cancel()
for operation_id, info in list(self._active_tasks.items()):
if not info.bg_task.done():
info.bg_task.cancel()
async def _log_progress_if_due(self):
"""Log progress stats every PROGRESS_LOG_INTERVAL seconds."""
"""Log progress stats every PROGRESS_LOG_INTERVAL seconds.
Emits four kinds of lines:
* [WORKER_STATS] - aggregate slots / pool / global pending counts
* [WORKER_TASK] - one line per in-flight task with age + stage
* [STUCK_STACK] - async stack trace for tasks past stuck thresholds
* [DB_WAITS] - any non-idle hindsight session waiting on a lock
"""
now = time.time()
if now - self._last_progress_log < PROGRESS_LOG_INTERVAL:
return
@@ -721,13 +792,15 @@ class WorkerPoller:
active_tasks = dict(self._active_tasks)
consolidation_count = in_flight_by_type.get("consolidation", 0)
available_slots = self._max_slots - in_flight
available_consolidation_slots = self._consolidation_max_slots - consolidation_count
non_consolidation_in_flight = max(0, in_flight - consolidation_count)
non_consolidation_max = max(0, self._max_slots - self._consolidation_max_slots)
available_slots = max(0, non_consolidation_max - non_consolidation_in_flight)
available_consolidation_slots = max(0, self._consolidation_max_slots - consolidation_count)
# Build local processing breakdown
# Build local processing breakdown (aggregate counts)
task_groups: dict[tuple[str, str], int] = {}
for op_type, bank_id, _, _ in active_tasks.values():
key = (op_type, bank_id)
for info in active_tasks.values():
key = (info.op_type, info.bank_id)
task_groups[key] = task_groups.get(key, 0) + 1
processing_info = [f"{op}:{bank}({cnt})" for (op, bank), cnt in task_groups.items()]
@@ -739,13 +812,42 @@ class WorkerPoller:
schemas = await self._get_schemas()
global_pending = 0
all_worker_counts: dict[str, int] = {}
# operation_type -> aggregated bucket counts across schemas
pending_breakdown: dict[str, dict[str, int]] = {}
async with self._pool.acquire() as conn:
for schema in schemas:
table = fq_table("async_operations", schema)
row = await conn.fetchrow(f"SELECT COUNT(*) as count FROM {table} WHERE status = 'pending'")
global_pending += row["count"] if row else 0
# Bucket pending rows by the same predicates the claim query
# filters on, so an operator can see why pending > 0 but
# nothing is being claimed (orphaned batch_retain parents,
# retry backoff, etc.).
breakdown_rows = await conn.fetch(
f"""
SELECT
operation_type,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE task_payload IS NULL) AS payload_null,
COUNT(*) FILTER (
WHERE next_retry_at IS NOT NULL AND next_retry_at > now()
) AS retry_blocked,
COUNT(*) FILTER (WHERE worker_id IS NOT NULL) AS assigned
FROM {table}
WHERE status = 'pending'
GROUP BY operation_type
"""
)
for br in breakdown_rows:
op_type = br["operation_type"] or "unknown"
bucket = pending_breakdown.setdefault(
op_type, {"total": 0, "payload_null": 0, "retry_blocked": 0, "assigned": 0}
)
bucket["total"] += br["total"]
bucket["payload_null"] += br["payload_null"]
bucket["retry_blocked"] += br["retry_blocked"]
bucket["assigned"] += br["assigned"]
global_pending += br["total"]
worker_rows = await conn.fetch(
f"""
@@ -765,6 +867,11 @@ class WorkerPoller:
other_workers.append(f"{wid}:{cnt}")
others_str = ", ".join(other_workers) if other_workers else "none"
# asyncpg pool stats - exhaustion presents as "everything slow",
# making it invisible without this line.
pool_str = self._format_pool_stats()
proc_str = self._format_proc_stats()
# Display None as "default" in logs
schemas_str = ", ".join(s if s else "default" for s in schemas)
logger.info(
@@ -773,12 +880,209 @@ class WorkerPoller:
f"available={available_slots} (consolidation={available_consolidation_slots}) | "
f"global: pending={global_pending} (schemas: {schemas_str}) | "
f"others: {others_str} | "
f"pool: {pool_str} | "
f"proc: {proc_str} | "
f"my_active: {processing_str}"
)
# Pending breakdown - explains why pending rows aren't being claimed
# (orphaned batch_retain parents have payload_null > 0, retry storms
# show up as retry_blocked, etc.). Skip when nothing is pending so
# the line doesn't add noise on idle deployments.
if global_pending > 0:
self._log_pending_breakdown(pending_breakdown)
# Per-task lines, sorted oldest-first so stuck tasks bubble to the top.
self._log_per_task_lines(active_tasks, now=time.monotonic())
# DB lock waits - separate from per-task lines because a single
# blocking session can wedge many tasks.
await self._log_db_waits()
except Exception as e:
logger.debug(f"Failed to log progress stats: {e}")
def _format_proc_stats(self) -> str:
"""Render lightweight process memory stats. Returns 'unavailable' if introspection fails."""
try:
import resource
# ru_maxrss is bytes on macOS, kilobytes on Linux. Detect by checking platform.
import sys
usage = resource.getrusage(resource.RUSAGE_SELF)
rss = usage.ru_maxrss
if sys.platform != "darwin":
rss *= 1024 # Linux reports KB
rss_mb = rss / (1024 * 1024)
return f"rss_mb={rss_mb:.0f}"
except Exception as e:
logger.debug(f"Process stats unavailable: {e}")
return "unavailable"
def _format_pool_stats(self) -> str:
"""Render asyncpg pool stats. Returns 'unavailable' if pool can't be introspected."""
pool = self._pool
try:
# asyncpg.Pool exposes _holders / _queue internally; fall back gracefully
# to public methods if the layout ever changes.
size = pool.get_size() if hasattr(pool, "get_size") else len(getattr(pool, "_holders", []))
free = pool.get_idle_size() if hasattr(pool, "get_idle_size") else None
min_size = pool.get_min_size() if hasattr(pool, "get_min_size") else None
max_size = pool.get_max_size() if hasattr(pool, "get_max_size") else None
queue = getattr(pool, "_queue", None)
waiters = queue.qsize() if queue is not None and hasattr(queue, "qsize") else None
parts = [f"size={size}"]
if min_size is not None and max_size is not None:
parts.append(f"limits={min_size}-{max_size}")
if free is not None:
parts.append(f"idle={free}")
parts.append(f"in_use={size - free}")
if waiters is not None:
parts.append(f"waiters={waiters}")
return " ".join(parts)
except Exception as e:
logger.debug(f"Pool stats unavailable: {e}")
return "unavailable"
def _log_pending_breakdown(self, breakdown: dict[str, dict[str, int]]) -> None:
"""Emit one [PENDING_BREAKDOWN] line bucketing pending rows by claimability.
Each bucket mirrors a predicate in the claim query:
* payload_null - row has no task_payload (e.g. batch_retain parent
whose reconciliation never fired); claim query
skips it forever
* retry_blocked - next_retry_at is still in the future
* assigned - worker_id already set; another worker owns it
``claimable`` is the residual that *should* be picked up on the next
poll. If ``claimable > 0`` while workers report free slots, the bug is
somewhere else (lock contention, tenant discovery, etc.) - this line
narrows the search.
"""
if not breakdown:
return
parts = []
for op_type in sorted(breakdown):
b = breakdown[op_type]
claimable = b["total"] - b["payload_null"] - b["retry_blocked"] - b["assigned"]
parts.append(
f"{op_type}: total={b['total']} claimable={claimable} "
f"payload_null={b['payload_null']} retry_blocked={b['retry_blocked']} "
f"assigned={b['assigned']}"
)
logger.info(f"[PENDING_BREAKDOWN] {' | '.join(parts)}")
def _log_per_task_lines(self, active_tasks: dict[str, ActiveTaskInfo], now: float) -> None:
"""Emit one [WORKER_TASK] line per in-flight task and dump stuck stacks.
Sorted by age desc so the oldest (most likely stuck) tasks appear first.
"""
if not active_tasks:
return
# Sort by age descending; tie-break on op_id for determinism.
ordered = sorted(
active_tasks.items(),
key=lambda kv: (now - kv[1].started_at, kv[0]),
reverse=True,
)
for op_id, info in ordered:
age_s = now - info.started_at
holder = info.stage_holder
stage = holder.stage if holder is not None else "unknown"
stage_age_s = (now - holder.updated_at) if holder is not None else 0.0
stuck_marker = "[STUCK?] " if age_s >= STUCK_STACK_INITIAL_THRESHOLD_S else ""
schema_part = f" schema={info.schema}" if info.schema else ""
logger.info(
f"[WORKER_TASK] {stuck_marker}op={op_id} type={info.task_type} "
f"op_type={info.op_type} bank={info.bank_id}{schema_part} "
f"age={age_s:.0f}s stage={stage} stage_age={stage_age_s:.0f}s"
)
self._maybe_dump_stuck_stack(op_id, info, age_s)
def _maybe_dump_stuck_stack(self, op_id: str, info: ActiveTaskInfo, age_s: float) -> None:
"""Dump a coroutine stack for tasks that crossed a stuck threshold.
Each task gets one dump per threshold (5min, 10min, 20min, 40min...),
gated by `info.last_stack_dump_threshold` so logs don't flood for tasks
that legitimately take a long time (large LLM jobs, schema-retry loops).
"""
if age_s < STUCK_STACK_INITIAL_THRESHOLD_S:
return
# Find the largest doubling-threshold that the task has crossed.
threshold = STUCK_STACK_INITIAL_THRESHOLD_S
crossed = STUCK_STACK_INITIAL_THRESHOLD_S
while threshold <= age_s and threshold <= STUCK_STACK_MAX_THRESHOLD_S:
crossed = threshold
threshold *= 2
if crossed <= info.last_stack_dump_threshold:
return
info.last_stack_dump_threshold = crossed
try:
buf = io.StringIO()
info.bg_task.print_stack(file=buf, limit=15)
stage = info.stage_holder.stage if info.stage_holder else "unknown"
logger.warning(
f"[STUCK_STACK] op={op_id} type={info.task_type} bank={info.bank_id} "
f"age={age_s:.0f}s threshold={crossed}s stage={stage}\n{buf.getvalue()}"
)
except Exception as e:
# Stack capture is best-effort - never crash the polling loop over it.
logger.debug(f"Failed to capture stack for {op_id}: {e}")
async def _log_db_waits(self) -> None:
"""Log any non-idle hindsight session that's waiting on a lock or other resource.
Catches the case where a coroutine appears 'fine' from Python's perspective
but is blocked on a Postgres row lock - which is exactly how the 3-phase
retain pipeline deadlock would present.
"""
try:
async with self._pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT
pid,
application_name,
wait_event_type,
wait_event,
state,
EXTRACT(EPOCH FROM (now() - query_start))::int AS age_s,
LEFT(query, 200) AS query
FROM pg_stat_activity
WHERE datname = current_database()
AND state IS NOT NULL
AND state != 'idle'
AND wait_event IS NOT NULL
AND wait_event_type NOT IN ('Activity', 'Client')
ORDER BY age_s DESC NULLS LAST
LIMIT 20
"""
)
except Exception as e:
# pg_stat_activity may be restricted on managed Postgres - degrade silently.
logger.debug(f"DB waits query failed: {e}")
return
if not rows:
return
for r in rows:
logger.info(
f"[DB_WAITS] pid={r['pid']} app={r['application_name']} "
f"wait={r['wait_event_type']}.{r['wait_event']} state={r['state']} "
f"age={r['age_s']}s query={r['query']!r}"
)
@property
def worker_id(self) -> str:
"""Get the worker ID."""
@@ -0,0 +1,59 @@
"""Stage breadcrumbs for in-flight worker tasks.
The worker poller binds a `StageHolder` to each task's contextvar scope.
Engine code calls `set_stage("retain.facts.llm")` at phase boundaries; the
poller reads the holder periodically to surface what each in-flight task is
currently doing in `WORKER_STATS` / `WORKER_TASK` log lines.
Outside a worker context the contextvar is unset and `set_stage` is a no-op,
so engine code is safe to call from sync HTTP requests, tests, or the CLI
without any setup.
"""
from __future__ import annotations
import time
from contextvars import ContextVar
from dataclasses import dataclass, field
@dataclass
class StageHolder:
"""Mutable container for the current task's stage label."""
stage: str = "init"
updated_at: float = field(default_factory=time.monotonic)
_current_holder: ContextVar[StageHolder | None] = ContextVar("hindsight_stage_holder", default=None)
def bind_holder(holder: StageHolder):
"""Bind a holder to the current async context.
Must be called from inside the task coroutine itself (not from the
spawning code) so the binding lives in the task's own contextvar scope.
Returns the token that can be passed to `_current_holder.reset()` if
the binding ever needs to be unwound.
"""
return _current_holder.set(holder)
def set_stage(name: str) -> None:
"""Update the current task's stage label.
No-op when called outside a worker task context (e.g. from a sync HTTP
request, a test, or the CLI). Cheap enough to call per-phase.
"""
holder = _current_holder.get()
if holder is None:
return
holder.stage = name
holder.updated_at = time.monotonic()
def get_stage() -> str | None:
"""Return the current stage label, or None if no holder is bound."""
holder = _current_holder.get()
return holder.stage if holder is not None else None
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api-slim"
version = "0.5.0"
version = "0.5.1"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -433,3 +433,130 @@ async def test_config_retain_batch_tokens_respected(memory, request_context):
# Even small batches use parent-child pattern now (simpler code path)
assert "child_operations" in status
assert status["result_metadata"]["num_sub_batches"] == 1
async def _child_metadata(memory, bank_id: str, parent_operation_id: str, request_context):
"""Fetch the first child operation's result_metadata for a parent batch_retain."""
parent = await memory.get_operation_status(
bank_id=bank_id,
operation_id=parent_operation_id,
request_context=request_context,
)
assert parent["status"] == "completed", parent
assert parent["child_operations"], "expected at least one child operation"
child_id = parent["child_operations"][0]["operation_id"]
child = await memory.get_operation_status(
bank_id=bank_id,
operation_id=child_id,
request_context=request_context,
)
return child["result_metadata"]
@pytest.mark.asyncio
async def test_retain_records_user_provided_document_ids(memory, request_context):
"""User-supplied document_ids land in child op result_metadata.document_ids."""
bank_id = "test_doc_ids_user_supplied"
d1 = str(uuid.uuid4())
d2 = str(uuid.uuid4())
contents = [
{"content": "User-supplied doc one content.", "document_id": d1},
{"content": "User-supplied doc two content.", "document_id": d2},
]
result = await memory.submit_async_retain(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
await asyncio.sleep(0.2)
meta = await _child_metadata(memory, bank_id, result["operation_id"], request_context)
assert "document_ids" in meta, meta
assert set(meta["document_ids"]) == {d1, d2}
@pytest.mark.asyncio
async def test_retain_records_generated_document_id(memory, request_context):
"""With no document_ids supplied, retain records the single generated id."""
bank_id = "test_doc_ids_generated"
contents = [
{"content": "Generated doc item one."},
{"content": "Generated doc item two."},
]
result = await memory.submit_async_retain(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
await asyncio.sleep(0.2)
meta = await _child_metadata(memory, bank_id, result["operation_id"], request_context)
assert "document_ids" in meta, meta
assert isinstance(meta["document_ids"], list)
assert len(meta["document_ids"]) == 1
# Must be a valid UUID string (generated by the orchestrator)
uuid.UUID(meta["document_ids"][0])
@pytest.mark.asyncio
async def test_retain_records_shared_document_id_once(memory, request_context):
"""Items sharing one document_id record it exactly once (idempotent set-append)."""
bank_id = "test_doc_ids_shared"
shared = str(uuid.uuid4())
# Duplicate per-item doc_ids are rejected up front, so shared-doc mode
# is exercised by a single item carrying the id.
contents = [{"content": "Shared doc, chunk A.", "document_id": shared}]
result = await memory.submit_async_retain(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
await asyncio.sleep(0.2)
meta = await _child_metadata(memory, bank_id, result["operation_id"], request_context)
assert meta.get("document_ids") == [shared]
@pytest.mark.asyncio
async def test_get_operation_status_include_payload(memory, request_context):
"""include_payload=True returns the original submission payload; default omits it."""
bank_id = "test_include_payload"
contents = [{"content": "Payload roundtrip test item."}]
result = await memory.submit_async_retain(
bank_id=bank_id,
contents=contents,
request_context=request_context,
)
await asyncio.sleep(0.2)
parent = await memory.get_operation_status(
bank_id=bank_id,
operation_id=result["operation_id"],
request_context=request_context,
)
child_id = parent["child_operations"][0]["operation_id"]
# Default: no payload
without = await memory.get_operation_status(
bank_id=bank_id,
operation_id=child_id,
request_context=request_context,
)
assert without.get("task_payload") is None
# With flag: payload populated
with_payload = await memory.get_operation_status(
bank_id=bank_id,
operation_id=child_id,
request_context=request_context,
include_payload=True,
)
payload = with_payload.get("task_payload")
assert payload is not None, with_payload
assert payload.get("bank_id") == bank_id
assert payload.get("contents")
assert payload["contents"][0]["content"] == "Payload roundtrip test item."
@@ -0,0 +1,106 @@
"""Verify that BankTemplateConfig exposes every hierarchical field that
_CONFIGURABLE_FIELDS already accepts at the engine layer.
This test guards the fix for the gap described in the upstream PR title
"fix(bank-template): align BankTemplateConfig with _CONFIGURABLE_FIELDS".
Each new field is POSTed through /v1/default/banks/{id}/import and then
read back via the bank-config endpoint; assertion is that the applied
value round-trips through the engine.
Runs via: uv run pytest tests/test_bank_template_configurable_fields.py -v
The api_client fixture (shared with tests/test_bank_templates.py) wraps
create_app(memory, initialize_memory=False) in an httpx.ASGITransport
with base_url http://test — in-process, no network, no tenant extension.
Copy the fixture inline here so the test file does not depend on a
conftest we do not ship in the patch.
"""
from __future__ import annotations
from datetime import datetime
import httpx
import pytest
import pytest_asyncio
from hindsight_api.api import create_app
from hindsight_api.api.http import BankTemplateConfig
# Each tuple is (field_name, applied_value). Values chosen to differ
# visibly from defaults so round-trip bugs surface.
NEW_FIELDS: list[tuple[str, object]] = [
("retain_default_strategy", "strategy-a"),
("retain_strategies", {"strategy-a": {"mode": "concise", "max_tokens": 512}}),
("retain_chunk_batch_size", 7),
("mcp_enabled_tools", ["list_banks", "get_bank_profile"]),
("consolidation_llm_batch_size", 11),
("consolidation_source_facts_max_tokens", 2048),
("consolidation_source_facts_max_tokens_per_observation", 256),
("max_observations_per_scope", 13),
("reflect_source_facts_max_tokens", 4096),
("llm_gemini_safety_settings", [{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}]),
]
@pytest_asyncio.fixture
async def api_client(memory):
"""Matches the fixture in tests/test_bank_templates.py — in-process
ASGI test client, no tenant extension, no auth."""
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_config_{datetime.now().timestamp()}"
def test_bank_template_config_declares_every_configurable_field():
"""Pydantic-level guard: every field in NEW_FIELDS must be a declared
attribute of BankTemplateConfig so get_config_updates() picks it up."""
declared = set(BankTemplateConfig.model_fields.keys())
missing = [name for name, _ in NEW_FIELDS if name not in declared]
assert not missing, f"BankTemplateConfig missing fields: {missing}"
@pytest.mark.asyncio
@pytest.mark.parametrize("field_name,applied_value", NEW_FIELDS, ids=[n for n, _ in NEW_FIELDS])
async def test_new_field_round_trips_through_import(
api_client: httpx.AsyncClient,
bank_id: str,
field_name: str,
applied_value: object,
):
"""POST a minimal manifest with one new field set, then read bank
config back and assert the value made it through.
Bank config response shape per upstream's test_import_applies_config:
top-level keys are resolved hierarchical config; per-bank overrides
live under config["overrides"][<field>]. Assert on the override slot.
"""
unique_bank_id = f"{bank_id}_{field_name}"
manifest = {
"version": "1",
"bank": {field_name: applied_value},
}
resp = await api_client.post(
f"/v1/default/banks/{unique_bank_id}/import",
json=manifest,
)
assert resp.status_code == 200, resp.text
# Read bank config back — field must reflect the applied value
# under the "overrides" slot, matching upstream's own test shape.
read = await api_client.get(f"/v1/default/banks/{unique_bank_id}/config")
assert read.status_code == 200, read.text
config = read.json()
overrides = config.get("overrides", {})
assert overrides.get(field_name) == applied_value, (
f"round-trip mismatch for {field_name}: "
f"sent {applied_value!r}, got {overrides.get(field_name)!r} "
f"(full overrides: {overrides!r})"
)
@@ -0,0 +1,149 @@
"""
Regression tests for chunk_storage.store_chunks_batch idempotency.
Covers vectorize-io/hindsight#977: re-submitting a retain under the same
document_id must not fail with ``UniqueViolationError`` on ``pk_chunks``.
The upstream retain paths (cascade delete on first batch, delta retain)
should usually prevent a chunk_id collision, but any bug in those paths
used to surface as a raw Postgres constraint violation. ``store_chunks_batch``
is now idempotent: inserting the same ``chunk_id`` twice overwrites the
existing row rather than raising.
"""
from datetime import datetime, timezone
import pytest
from hindsight_api.engine.retain import chunk_storage
from hindsight_api.engine.retain.types import ChunkMetadata
def _ts() -> float:
return datetime.now(timezone.utc).timestamp()
async def _seed_bank_and_document(conn, bank_id: str, document_id: str) -> None:
"""Insert the minimum rows required for the chunks FK to pass."""
await conn.execute(
"INSERT INTO banks (bank_id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING",
bank_id,
bank_id,
)
await conn.execute(
"""
INSERT INTO documents (id, bank_id, original_text, content_hash)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id, bank_id) DO NOTHING
""",
document_id,
bank_id,
"seed",
"seed-hash",
)
@pytest.mark.asyncio
async def test_store_chunks_batch_is_idempotent_for_same_chunk_id(memory):
"""
Regression for #977.
Directly exercises the chunk insert path: inserting a ChunkMetadata with
a chunk_index that already exists (i.e., the same chunk_id) must not
raise. The new content should overwrite the old one.
"""
bank_id = f"test_chunk_upsert_{_ts()}"
document_id = "doc-upsert-regression"
pool = await memory._get_pool()
try:
async with pool.acquire() as conn:
await _seed_bank_and_document(conn, bank_id, document_id)
# First insert — fresh chunks at indices 0, 1, 2.
v1 = [
ChunkMetadata(chunk_text="alpha", fact_count=1, content_index=0, chunk_index=0),
ChunkMetadata(chunk_text="beta", fact_count=1, content_index=0, chunk_index=1),
ChunkMetadata(chunk_text="gamma", fact_count=1, content_index=0, chunk_index=2),
]
v1_map = await chunk_storage.store_chunks_batch(conn, bank_id, document_id, v1)
assert set(v1_map.keys()) == {0, 1, 2}
# Second insert — overlapping chunk_index (1 and 2) with new text,
# plus a fresh chunk at index 3. Before the fix this raised
# asyncpg.exceptions.UniqueViolationError on pk_chunks; after the
# fix the conflicting rows are overwritten and the new one is
# inserted.
v2 = [
ChunkMetadata(chunk_text="beta-updated", fact_count=1, content_index=0, chunk_index=1),
ChunkMetadata(chunk_text="gamma-updated", fact_count=1, content_index=0, chunk_index=2),
ChunkMetadata(chunk_text="delta", fact_count=1, content_index=0, chunk_index=3),
]
v2_map = await chunk_storage.store_chunks_batch(conn, bank_id, document_id, v2)
assert set(v2_map.keys()) == {1, 2, 3}
# Verify the stored state matches the upserted content.
rows = await conn.fetch(
"""
SELECT chunk_index, chunk_text, content_hash
FROM chunks
WHERE document_id = $1 AND bank_id = $2
ORDER BY chunk_index
""",
document_id,
bank_id,
)
by_index = {row["chunk_index"]: row for row in rows}
assert set(by_index.keys()) == {0, 1, 2, 3}, (
"Expected four chunks total after upsert (0 untouched, 1-2 overwritten, 3 new)"
)
assert by_index[0]["chunk_text"] == "alpha", "Untouched chunk must be preserved"
assert by_index[1]["chunk_text"] == "beta-updated", "Conflicting chunk must be overwritten"
assert by_index[2]["chunk_text"] == "gamma-updated", "Conflicting chunk must be overwritten"
assert by_index[3]["chunk_text"] == "delta", "New chunk must be inserted"
# content_hash should reflect the new text, not the original.
assert by_index[1]["content_hash"] == chunk_storage.compute_chunk_hash("beta-updated")
assert by_index[2]["content_hash"] == chunk_storage.compute_chunk_hash("gamma-updated")
finally:
async with pool.acquire() as conn:
await conn.execute("DELETE FROM chunks WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM documents WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM banks WHERE bank_id = $1", bank_id)
@pytest.mark.asyncio
async def test_store_chunks_batch_second_call_with_identical_payload(memory):
"""
The exact #977 shape: ``store_chunks_batch`` called twice with the same
chunks must succeed both times (the second call is a no-op in terms of
stored content, but must not raise).
"""
bank_id = f"test_chunk_upsert_identical_{_ts()}"
document_id = "doc-upsert-identical"
pool = await memory._get_pool()
try:
async with pool.acquire() as conn:
await _seed_bank_and_document(conn, bank_id, document_id)
chunks = [
ChunkMetadata(chunk_text=f"chunk-{i}", fact_count=1, content_index=0, chunk_index=i)
for i in range(5)
]
await chunk_storage.store_chunks_batch(conn, bank_id, document_id, chunks)
# Second call with identical chunks — must not raise.
await chunk_storage.store_chunks_batch(conn, bank_id, document_id, chunks)
count = await conn.fetchval(
"SELECT COUNT(*) FROM chunks WHERE document_id = $1 AND bank_id = $2",
document_id,
bank_id,
)
assert count == 5, "Second identical insert should not duplicate rows"
finally:
async with pool.acquire() as conn:
await conn.execute("DELETE FROM chunks WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM documents WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM banks WHERE bank_id = $1", bank_id)
@@ -5,7 +5,7 @@ Tests the Cohere cross-encoder implementation, including Azure AI Foundry endpoi
"""
import os
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
@@ -28,7 +28,7 @@ class TestCohereCrossEncoder:
assert encoder.api_key == "test_key"
assert encoder.model == "rerank-english-v3.0"
assert encoder._client is None
assert encoder._httpx_client is None
assert encoder._http_client is None
# Mock the cohere import
mock_cohere = MagicMock()
@@ -36,7 +36,7 @@ class TestCohereCrossEncoder:
with patch.dict("sys.modules", {"cohere": mock_cohere}):
await encoder.initialize()
assert encoder._client is not None
assert encoder._httpx_client is None
assert encoder._http_client is None
mock_cohere.Client.assert_called_once_with(api_key="test_key", timeout=60.0)
@pytest.mark.asyncio
@@ -52,9 +52,14 @@ class TestCohereCrossEncoder:
await encoder.initialize()
assert encoder._httpx_client is not None
assert encoder._http_client is not None
assert encoder._client is None
assert isinstance(encoder._httpx_client, httpx.Client)
assert isinstance(encoder._http_client._async_client, httpx.AsyncClient)
assert encoder._http_client.include_top_n is False
assert (
encoder._http_client.rerank_url
== "https://my-endpoint.inference.ai.azure.com/models/cohere-rerank-v3-english/invoke"
)
@pytest.mark.asyncio
async def test_initialization_missing_package(self):
@@ -150,7 +155,7 @@ class TestCohereCrossEncoder:
await encoder.initialize()
# Mock httpx response
# Mock async httpx response
mock_response = MagicMock()
mock_response.json.return_value = {
"results": [
@@ -159,8 +164,9 @@ class TestCohereCrossEncoder:
{"index": 2, "relevance_score": 0.5},
]
}
mock_response.raise_for_status = MagicMock()
encoder._httpx_client.post = MagicMock(return_value=mock_response)
encoder._http_client._async_client.post = AsyncMock(return_value=mock_response)
pairs = [
("What is Python?", "Python is a programming language"),
@@ -174,13 +180,15 @@ class TestCohereCrossEncoder:
assert scores == [0.9, 0.7, 0.5]
# Verify httpx.post was called with correct URL and payload
encoder._httpx_client.post.assert_called_once()
call_args = encoder._httpx_client.post.call_args
encoder._http_client._async_client.post.assert_called_once()
call_args = encoder._http_client._async_client.post.call_args
assert call_args[0][0] == "https://my-endpoint.inference.ai.azure.com/models/cohere-rerank-v3-english/invoke"
assert call_args.kwargs["json"]["model"] == "cohere-rerank-v3-english"
assert call_args.kwargs["json"]["query"] == "What is Python?"
assert len(call_args.kwargs["json"]["documents"]) == 3
assert call_args.kwargs["json"]["return_documents"] is False
# Azure endpoints expect no top_n in the body
assert "top_n" not in call_args.kwargs["json"]
@pytest.mark.asyncio
async def test_predict_multiple_queries(self):
@@ -281,7 +289,7 @@ class TestCohereCrossEncoder:
request=MagicMock(),
response=MagicMock(status_code=404),
)
encoder._httpx_client.post = MagicMock(return_value=mock_response)
encoder._http_client._async_client.post = AsyncMock(return_value=mock_response)
pairs = [("What is Python?", "Python is a programming language")]
@@ -15,7 +15,12 @@ import pytest
from sqlalchemy import create_engine, text
from hindsight_api import MemoryEngine, RequestContext
from hindsight_api.engine.cross_encoder import CohereCrossEncoder, LocalSTCrossEncoder, ZeroEntropyCrossEncoder
from hindsight_api.engine.cross_encoder import (
CohereCrossEncoder,
LocalSTCrossEncoder,
SiliconFlowCrossEncoder,
ZeroEntropyCrossEncoder,
)
from hindsight_api.engine.embeddings import CohereEmbeddings, LocalSTEmbeddings, OpenAIEmbeddings
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer
from hindsight_api.engine.task_backend import SyncTaskBackend
@@ -739,3 +744,58 @@ class TestZeroEntropyCrossEncoder:
assert all(isinstance(s, float) for s in scores)
# The first result should be most relevant
assert scores[0] > scores[2], "Direct answer should score higher than unrelated text"
# =============================================================================
# SiliconFlow Reranker Tests
# =============================================================================
def has_siliconflow_api_key() -> bool:
"""Check if SiliconFlow API key is available."""
return bool(os.environ.get("SILICONFLOW_API_KEY"))
def get_siliconflow_api_key() -> str:
"""Get SiliconFlow API key from environment."""
return os.environ.get("SILICONFLOW_API_KEY", "")
@pytest.fixture(scope="module")
def siliconflow_cross_encoder():
"""Create SiliconFlow cross-encoder instance."""
if not has_siliconflow_api_key():
pytest.skip("SiliconFlow API key not available (set SILICONFLOW_API_KEY)")
cross_encoder = SiliconFlowCrossEncoder(
api_key=get_siliconflow_api_key(),
model="BAAI/bge-reranker-v2-m3",
)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(cross_encoder.initialize())
finally:
loop.close()
return cross_encoder
class TestSiliconFlowCrossEncoder:
"""Tests for SiliconFlow cross-encoder/reranker."""
def test_siliconflow_cross_encoder_initialization(self, siliconflow_cross_encoder):
"""Test that SiliconFlow cross-encoder initializes correctly."""
assert siliconflow_cross_encoder.provider_name == "siliconflow"
@pytest.mark.asyncio
async def test_siliconflow_cross_encoder_predict(self, siliconflow_cross_encoder):
"""Test that SiliconFlow cross-encoder can score pairs."""
pairs = [
("What is the capital of France?", "Paris is the capital of France."),
("What is the capital of France?", "The Eiffel Tower is in Paris."),
("What is the capital of France?", "Python is a programming language."),
]
scores = await siliconflow_cross_encoder.predict(pairs)
assert len(scores) == 3
assert all(isinstance(s, float) for s in scores)
assert scores[0] > scores[2], "Direct answer should score higher than unrelated text"
@@ -98,7 +98,7 @@ async def test_hierarchical_fields_categorization():
assert "retain_chunk_batch_size" in configurable
# Verify count is correct
assert len(configurable) == 22
assert len(configurable) == 25
# Verify credential fields (NEVER exposed)
assert "llm_api_key" in credentials
@@ -209,6 +209,47 @@ async def test_config_validation_rejects_static_fields(memory, request_context):
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_config_validation_rejects_malformed_entity_labels(memory, request_context):
"""Test that passing strings instead of LabelGroup dicts to entity_labels raises ValueError.
Regression test for the fix in PR #902: entity_labels PATCH must validate the
format before saving to prevent silent corruption that previously caused 500s on
subsequent retain calls (reported in issue #946).
"""
bank_id = "test-entity-labels-validation"
try:
await memory.get_bank_profile(bank_id, request_context=request_context)
resolver = ConfigResolver(pool=memory._pool)
# String list instead of LabelGroup dicts must raise ValueError, not silently accept.
# Previously this produced HTTP 200, then 500 on the next retain call (issue #946).
with pytest.raises(ValueError, match="Invalid entity_labels format"):
await resolver.update_bank_config(
bank_id,
{"entity_labels": ["person", "client", "tool"]},
)
# The correct LabelGroup format must succeed
await resolver.update_bank_config(
bank_id,
{
"entity_labels": [
{
"key": "kind",
"type": "value",
"values": [{"value": "person"}, {"value": "client"}],
}
]
},
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_config_freshness_across_updates(memory, request_context):
"""Test that config changes are immediately visible (no stale cache)."""
@@ -394,10 +435,16 @@ async def test_config_get_bank_config_no_static_or_credential_fields_leak(memory
# SECURITY: Verify specific sensitive fields are NOT present
sensitive_fields = [
"database_url", "api_port", "host", "worker_count", # Infrastructure
"llm_api_key", "llm_base_url", # Credentials
"retain_llm_api_key", "reflect_llm_api_key", # More credentials
"llm_provider", "llm_model", # Not configurable (need presets)
"database_url",
"api_port",
"host",
"worker_count", # Infrastructure
"llm_api_key",
"llm_base_url", # Credentials
"retain_llm_api_key",
"reflect_llm_api_key", # More credentials
"llm_provider",
"llm_model", # Not configurable (need presets)
]
for field in sensitive_fields:
assert field not in config, (
@@ -411,7 +458,7 @@ async def test_config_get_bank_config_no_static_or_credential_fields_leak(memory
assert field in config, f"Expected configurable field '{field}' missing from config"
# Should have a small number of configurable fields (not hundreds)
assert len(config) < 25, f"Too many fields returned: {len(config)}"
assert len(config) < 30, f"Too many fields returned: {len(config)}"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -0,0 +1,184 @@
"""
Regression tests for vectorize-io/hindsight#980.
Deterministic Postgres integrity-constraint violations (UniqueViolationError,
ForeignKeyViolationError, CheckViolationError, NotNullViolationError,
ExclusionViolationError) must NOT be retried by the worker — they will never
succeed on retry, and retrying just burns worker capacity for ~3 minutes
(3 retries × 60s) before finally giving up.
These tests verify that ``MemoryEngine.execute_task`` classifies
``asyncpg.exceptions.IntegrityConstraintViolationError`` as non-retryable
and marks the operation as failed on the first occurrence.
"""
import json
import uuid
from unittest.mock import AsyncMock, patch
import asyncpg
import pytest
from hindsight_api.worker.exceptions import RetryTaskAt
async def _ensure_bank(pool, bank_id: str) -> None:
"""Upsert a minimal bank row so FK on async_operations passes."""
await pool.execute(
"INSERT INTO banks (bank_id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING",
bank_id,
bank_id,
)
async def _create_pending_operation(pool, bank_id: str, operation_id: uuid.UUID) -> None:
"""Insert a pending batch_retain operation row for the test."""
payload = json.dumps(
{
"type": "batch_retain",
"operation_id": str(operation_id),
"bank_id": bank_id,
"contents": [{"content": "test", "document_id": "doc-1"}],
}
)
await pool.execute(
"""
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
VALUES ($1, $2, 'retain', 'pending', $3::jsonb)
""",
operation_id,
bank_id,
payload,
)
@pytest.mark.asyncio
async def test_unique_violation_marks_failed_without_retry(memory):
"""
UniqueViolationError must mark the operation as failed immediately, not
raise RetryTaskAt. This is the primary symptom from #977: re-submitting
retain caused PK collisions that the poller retried ~3 times before
giving up. With #980's fix, the first collision fails the task.
"""
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
operation_id = uuid.uuid4()
pool = await memory._get_pool()
await _ensure_bank(pool, bank_id)
await _create_pending_operation(pool, bank_id, operation_id)
# Synthesize a real asyncpg UniqueViolationError the way the server would
# raise it (matches the error observed in the bug report's logs).
unique_violation = asyncpg.exceptions.UniqueViolationError(
'duplicate key value violates unique constraint "pk_chunks"'
)
task_dict = {
"type": "batch_retain",
"operation_id": str(operation_id),
"bank_id": bank_id,
"contents": [{"content": "test", "document_id": "doc-1"}],
}
# Force _handle_batch_retain to raise the integrity error, isolating the
# execute_task exception-classification path.
with patch.object(memory, "_handle_batch_retain", side_effect=unique_violation):
# Must not raise RetryTaskAt — the whole point of the fix.
try:
await memory.execute_task(task_dict)
except RetryTaskAt as exc:
pytest.fail(
f"IntegrityConstraintViolationError must not be retried, but execute_task raised {exc!r}"
)
# The operation must be marked 'failed' (not left pending / retrying).
row = await pool.fetchrow(
"SELECT status, error_message FROM async_operations WHERE operation_id = $1",
operation_id,
)
assert row is not None, "Operation row disappeared"
assert row["status"] == "failed", (
f"Expected status='failed' after integrity violation, got {row['status']!r}"
)
assert row["error_message"] is not None
assert "pk_chunks" in row["error_message"]
# Cleanup
await pool.execute("DELETE FROM async_operations WHERE operation_id = $1", operation_id)
await pool.execute("DELETE FROM banks WHERE bank_id = $1", bank_id)
@pytest.mark.asyncio
async def test_foreign_key_violation_also_not_retried(memory):
"""
All subclasses of IntegrityConstraintViolationError are non-retryable —
verify ForeignKeyViolationError is classified the same way as
UniqueViolationError.
"""
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
operation_id = uuid.uuid4()
pool = await memory._get_pool()
await _ensure_bank(pool, bank_id)
await _create_pending_operation(pool, bank_id, operation_id)
fk_violation = asyncpg.exceptions.ForeignKeyViolationError(
"insert or update on table \"memory_units\" violates foreign key constraint \"fk_bank\""
)
task_dict = {
"type": "batch_retain",
"operation_id": str(operation_id),
"bank_id": bank_id,
"contents": [{"content": "test", "document_id": "doc-1"}],
}
with patch.object(memory, "_handle_batch_retain", side_effect=fk_violation):
try:
await memory.execute_task(task_dict)
except RetryTaskAt as exc:
pytest.fail(
f"ForeignKeyViolationError must not be retried, but execute_task raised {exc!r}"
)
row = await pool.fetchrow(
"SELECT status FROM async_operations WHERE operation_id = $1",
operation_id,
)
assert row["status"] == "failed"
await pool.execute("DELETE FROM async_operations WHERE operation_id = $1", operation_id)
await pool.execute("DELETE FROM banks WHERE bank_id = $1", bank_id)
@pytest.mark.asyncio
async def test_non_integrity_error_still_retried(memory):
"""
Sanity check: non-integrity errors (network errors, timeouts, value errors)
should STILL use the existing retry path — i.e., raise RetryTaskAt when
``_retry_count < 3``. Only integrity violations are the new non-retryable
class.
"""
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
operation_id = uuid.uuid4()
pool = await memory._get_pool()
await _ensure_bank(pool, bank_id)
await _create_pending_operation(pool, bank_id, operation_id)
task_dict = {
"type": "batch_retain",
"operation_id": str(operation_id),
"bank_id": bank_id,
"contents": [{"content": "test", "document_id": "doc-1"}],
# _retry_count = 0 (first attempt), so the existing retry path should fire.
}
transient_error = RuntimeError("transient connection blip")
with patch.object(memory, "_handle_batch_retain", side_effect=transient_error):
with pytest.raises(RetryTaskAt):
await memory.execute_task(task_dict)
await pool.execute("DELETE FROM async_operations WHERE operation_id = $1", operation_id)
await pool.execute("DELETE FROM banks WHERE bank_id = $1", bank_id)
@@ -0,0 +1,78 @@
"""
Regression test for the JinaMLXCrossEncoder import-error handling.
See: https://github.com/vectorize-io/hindsight/issues/994
Before the fix, the bare `except ImportError` around `import mlx_lm` masked
*any* ImportError raised transitively during mlx_lm's own initialization
(e.g. transformers 5.x's _LazyModule race producing
`ImportError: cannot import name 'AutoTokenizer' from 'transformers'`),
replacing it with a misleading "install mlx" message.
These tests verify:
1. A transitive ImportError raised from inside mlx_lm surfaces verbatim.
2. A genuine "package not installed" ImportError still produces the install hint.
"""
import sys
import types
from unittest.mock import patch
import pytest
from hindsight_api.engine.cross_encoder import JinaMLXCrossEncoder
def _stub_mlx_modules() -> dict[str, types.ModuleType]:
"""Stub mlx + mlx.core so `import mlx.core` succeeds even without mlx installed."""
import importlib.machinery
mlx = types.ModuleType("mlx")
mlx.__spec__ = importlib.machinery.ModuleSpec("mlx", loader=None)
mlx_core = types.ModuleType("mlx.core")
mlx_core.__spec__ = importlib.machinery.ModuleSpec("mlx.core", loader=None)
mlx.core = mlx_core
return {"mlx": mlx, "mlx.core": mlx_core}
@pytest.mark.asyncio
async def test_initialize_surfaces_transitive_import_error():
"""A transformers-lazy-load-style failure must propagate, not be masked."""
encoder = JinaMLXCrossEncoder()
real_import = __import__
def fake_import(name, *args, **kwargs):
if name == "mlx_lm" or name.startswith("mlx_lm."):
raise ImportError("cannot import name 'AutoTokenizer' from 'transformers'")
return real_import(name, *args, **kwargs)
sys.modules.pop("mlx_lm", None)
with patch.dict(sys.modules, _stub_mlx_modules()):
with patch("builtins.__import__", side_effect=fake_import):
with pytest.raises(ImportError, match="AutoTokenizer"):
await encoder.initialize()
@pytest.mark.asyncio
async def test_initialize_reports_install_hint_when_mlx_missing():
"""A genuine 'package not installed' error still gets the friendly install hint."""
encoder = JinaMLXCrossEncoder()
real_import = __import__
def fake_import(name, *args, **kwargs):
if name == "mlx_lm" or name.startswith("mlx_lm."):
raise ImportError("No module named 'mlx_lm'")
if name == "mlx" or name.startswith("mlx."):
raise ImportError("No module named 'mlx'")
return real_import(name, *args, **kwargs)
sys.modules.pop("mlx_lm", None)
sys.modules.pop("mlx", None)
sys.modules.pop("mlx.core", None)
with patch("builtins.__import__", side_effect=fake_import):
with pytest.raises(ImportError, match="mlx and mlx-lm are required"):
await encoder.initialize()
@@ -0,0 +1,72 @@
"""
Tests for OpenAICompatibleLLM._max_tokens_param_name.
Regression coverage for issue #978: Azure OpenAI + GPT-5 models were failing with
"'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead."
because PR #858 started sending 'max_tokens' whenever the openai provider had a
custom base_url. Reasoning models only accept 'max_completion_tokens', and Azure
OpenAI is fully OpenAI-API-compatible, so both cases must keep using the new
parameter name.
"""
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
def _make(provider: str, model: str, base_url: str = "") -> OpenAICompatibleLLM:
return OpenAICompatibleLLM(
provider=provider,
api_key="test-key",
base_url=base_url,
model=model,
)
class TestMaxTokensParamName:
def test_native_openai_uses_max_completion_tokens(self):
llm = _make("openai", "gpt-4o-mini")
assert llm._max_tokens_param_name() == "max_completion_tokens"
def test_openai_custom_base_url_falls_back_to_max_tokens(self):
"""Mistral/Together-style OpenAI-compatible endpoints need max_tokens (PR #858)."""
llm = _make("openai", "mistral-large-latest", base_url="https://api.mistral.ai/v1")
assert llm._max_tokens_param_name() == "max_tokens"
def test_azure_openai_uses_max_completion_tokens(self):
"""Regression for #978: Azure is fully OpenAI-API-compatible, not a third-party clone."""
llm = _make(
"openai",
"gpt-4o-mini",
base_url="https://my-resource.openai.azure.com/openai/v1/",
)
assert llm._max_tokens_param_name() == "max_completion_tokens"
def test_reasoning_model_always_uses_max_completion_tokens(self):
"""Regression for #978: GPT-5/o1/o3 reject max_tokens outright, base_url must not matter."""
# Azure + GPT-5 (exact reporter setup)
azure_gpt5 = _make(
"openai",
"gpt-5.4-nano",
base_url="https://my-resource.openai.azure.com/openai/v1/",
)
assert azure_gpt5._max_tokens_param_name() == "max_completion_tokens"
# Even a Mistral-style custom base_url must not downgrade a reasoning model
for model in ("gpt-5", "gpt-5-mini", "o1-mini", "o3", "deepseek-r1"):
llm = _make("openai", model, base_url="https://some-proxy.example.com/v1")
assert llm._max_tokens_param_name() == "max_completion_tokens", model
def test_groq_uses_max_completion_tokens(self):
llm = _make("groq", "openai/gpt-oss-120b", base_url="https://api.groq.com/openai/v1")
assert llm._max_tokens_param_name() == "max_completion_tokens"
def test_llamacpp_uses_max_completion_tokens(self):
llm = _make("llamacpp", "some-model", base_url="http://localhost:8080/v1")
assert llm._max_tokens_param_name() == "max_completion_tokens"
def test_ollama_uses_max_tokens(self):
llm = _make("ollama", "gemma3:12b", base_url="http://localhost:11434/v1")
assert llm._max_tokens_param_name() == "max_tokens"
def test_lmstudio_uses_max_tokens(self):
llm = _make("lmstudio", "openai/gpt-oss-20b", base_url="http://localhost:1234/v1")
assert llm._max_tokens_param_name() == "max_tokens"
@@ -0,0 +1,231 @@
"""
Tests for the internal recall configuration knobs used during mental model
refresh: recall_include_chunks, recall_max_tokens, recall_chunks_max_tokens.
These are exposed both as hierarchical config fields (env tenant bank)
and as overrides on a mental model's `trigger` JSONB field.
"""
import dataclasses
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from hindsight_api.engine.reflect.tools import tool_recall
from hindsight_api.engine.response_models import RecallResult as RecallResultModel
from hindsight_api.models import RequestContext
def _make_mock_engine():
engine = MagicMock()
engine.recall_async = AsyncMock(return_value=RecallResultModel(results=[], entities={}, chunks={}))
return engine
@pytest.fixture
def mock_request_context():
# internal=True bypasses the tenant extension, letting these unit tests
# exercise engine methods without standing up auth.
return RequestContext(internal=True)
class TestToolRecallIncludeChunks:
"""tool_recall must honor the include_chunks parameter (was hardcoded True)."""
@pytest.mark.asyncio
async def test_default_includes_chunks(self, mock_request_context):
engine = _make_mock_engine()
await tool_recall(engine, "bank-1", "q", mock_request_context)
kwargs = engine.recall_async.call_args.kwargs
assert kwargs["include_chunks"] is True
@pytest.mark.asyncio
async def test_include_chunks_false_propagates(self, mock_request_context):
engine = _make_mock_engine()
await tool_recall(engine, "bank-1", "q", mock_request_context, include_chunks=False)
kwargs = engine.recall_async.call_args.kwargs
assert kwargs["include_chunks"] is False
@pytest.mark.asyncio
async def test_max_chunk_tokens_propagates(self, mock_request_context):
engine = _make_mock_engine()
await tool_recall(
engine, "bank-1", "q", mock_request_context, max_chunk_tokens=2500, max_tokens=512
)
kwargs = engine.recall_async.call_args.kwargs
assert kwargs["max_chunk_tokens"] == 2500
assert kwargs["max_tokens"] == 512
class TestRecallConfigFields:
"""Hierarchical config fields for internal recall."""
def test_fields_exist_on_dataclass(self):
from hindsight_api.config import HindsightConfig
names = {f.name for f in dataclasses.fields(HindsightConfig)}
assert "recall_include_chunks" in names
assert "recall_max_tokens" in names
assert "recall_chunks_max_tokens" in names
def test_fields_are_configurable(self):
from hindsight_api.config import HindsightConfig
configurable = HindsightConfig.get_configurable_fields()
assert "recall_include_chunks" in configurable
assert "recall_max_tokens" in configurable
assert "recall_chunks_max_tokens" in configurable
def test_default_values(self):
from hindsight_api.config import (
DEFAULT_RECALL_CHUNKS_MAX_TOKENS,
DEFAULT_RECALL_INCLUDE_CHUNKS,
DEFAULT_RECALL_MAX_TOKENS,
)
assert DEFAULT_RECALL_INCLUDE_CHUNKS is True
assert DEFAULT_RECALL_MAX_TOKENS == 2048
assert DEFAULT_RECALL_CHUNKS_MAX_TOKENS == 1000
def test_env_var_constants(self):
from hindsight_api.config import (
ENV_RECALL_CHUNKS_MAX_TOKENS,
ENV_RECALL_INCLUDE_CHUNKS,
ENV_RECALL_MAX_TOKENS,
)
assert ENV_RECALL_INCLUDE_CHUNKS == "HINDSIGHT_API_RECALL_INCLUDE_CHUNKS"
assert ENV_RECALL_MAX_TOKENS == "HINDSIGHT_API_RECALL_MAX_TOKENS"
assert ENV_RECALL_CHUNKS_MAX_TOKENS == "HINDSIGHT_API_RECALL_CHUNKS_MAX_TOKENS"
@patch.dict(
"os.environ",
{
"HINDSIGHT_API_RECALL_INCLUDE_CHUNKS": "false",
"HINDSIGHT_API_RECALL_MAX_TOKENS": "777",
"HINDSIGHT_API_RECALL_CHUNKS_MAX_TOKENS": "333",
},
)
def test_from_env_reads_overrides(self):
from hindsight_api.config import HindsightConfig
config = HindsightConfig.from_env()
assert config.recall_include_chunks is False
assert config.recall_max_tokens == 777
assert config.recall_chunks_max_tokens == 333
class TestMentalModelTriggerRecallFields:
"""MentalModelTrigger Pydantic model accepts the new override fields."""
def test_trigger_accepts_new_fields(self):
from hindsight_api.api.http import MentalModelTrigger
trigger = MentalModelTrigger(
include_chunks=False,
recall_max_tokens=512,
recall_chunks_max_tokens=0,
)
assert trigger.include_chunks is False
assert trigger.recall_max_tokens == 512
assert trigger.recall_chunks_max_tokens == 0
def test_trigger_defaults_are_none(self):
from hindsight_api.api.http import MentalModelTrigger
trigger = MentalModelTrigger()
assert trigger.include_chunks is None
assert trigger.recall_max_tokens is None
assert trigger.recall_chunks_max_tokens is None
class TestRefreshTriggerWiring:
"""Verify mental-model refresh forwards trigger overrides into reflect_async kwargs."""
@pytest.mark.asyncio
async def test_trigger_overrides_passed_to_reflect_async(self, mock_request_context):
from hindsight_api.engine.memory_engine import MemoryEngine
from hindsight_api.engine.response_models import ReflectResult
engine = MemoryEngine.__new__(MemoryEngine)
async def fake_get_mental_model(bank_id, mental_model_id, request_context):
return {
"id": mental_model_id,
"source_query": "What do we know?",
"tags": [],
"trigger": {
"include_chunks": False,
"recall_max_tokens": 512,
"recall_chunks_max_tokens": 0,
"fact_types": ["world"],
},
}
captured = {}
async def fake_reflect_async(**kwargs):
captured.update(kwargs)
return ReflectResult(text="ok", based_on={})
async def fake_update_mental_model(*args, **kwargs):
return None
engine.get_mental_model = fake_get_mental_model
engine.reflect_async = fake_reflect_async
engine.update_mental_model = fake_update_mental_model
engine._operation_validator = None
engine._tenant_extension = None
await engine.refresh_mental_model(
bank_id="bank-1",
mental_model_id="mm-1",
request_context=mock_request_context,
)
assert captured["recall_include_chunks"] is False
assert captured["recall_max_tokens_override"] == 512
assert captured["recall_chunks_max_tokens_override"] == 0
assert captured["fact_types"] == ["world"]
@pytest.mark.asyncio
async def test_missing_trigger_fields_pass_none(self, mock_request_context):
from hindsight_api.engine.memory_engine import MemoryEngine
from hindsight_api.engine.response_models import ReflectResult
engine = MemoryEngine.__new__(MemoryEngine)
async def fake_get_mental_model(bank_id, mental_model_id, request_context):
return {"id": mental_model_id, "source_query": "q", "tags": [], "trigger": {}}
captured = {}
async def fake_reflect_async(**kwargs):
captured.update(kwargs)
return ReflectResult(text="ok", based_on={})
async def fake_update_mental_model(*args, **kwargs):
return None
engine.get_mental_model = fake_get_mental_model
engine.reflect_async = fake_reflect_async
engine.update_mental_model = fake_update_mental_model
engine._operation_validator = None
engine._tenant_extension = None
await engine.refresh_mental_model(
bank_id="bank-1",
mental_model_id="mm-1",
request_context=mock_request_context,
)
# When trigger fields are absent, None is forwarded so reflect_async falls back to bank/global config.
assert captured["recall_include_chunks"] is None
assert captured["recall_max_tokens_override"] is None
assert captured["recall_chunks_max_tokens_override"] is None
@@ -0,0 +1,80 @@
"""Regression test for #972: reflect sub-recalls must be marked internal.
When reflect calls search_observations or recall, the sub-recalls must use
``request_context.internal=True`` to avoid double-billing. The reflect caller
is already billed for the overall operation; sub-recalls are implementation
details that should not generate additional billing events.
"""
from dataclasses import dataclass, field
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 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 _mock_engine():
engine = MagicMock()
engine.recall_async = AsyncMock(
return_value=RecallResult(results=[], source_facts={})
)
return engine
class TestReflectInternalBilling:
"""Verify that reflect sub-recalls are marked internal (#972)."""
@pytest.mark.asyncio
async def test_search_observations_marks_recall_internal(self):
engine = _mock_engine()
ctx = _FakeRequestContext(api_key="k", internal=False)
await tool_search_observations(engine, "bank-1", "query", ctx)
engine.recall_async.assert_called_once()
passed_ctx = engine.recall_async.call_args.kwargs["request_context"]
assert passed_ctx.internal is True, "sub-recall must be internal"
@pytest.mark.asyncio
async def test_search_observations_preserves_original_context(self):
engine = _mock_engine()
ctx = _FakeRequestContext(api_key="k", internal=False)
await tool_search_observations(engine, "bank-1", "query", ctx)
assert ctx.internal is False, "original context must not be mutated"
@pytest.mark.asyncio
async def test_recall_marks_recall_internal(self):
engine = _mock_engine()
ctx = _FakeRequestContext(api_key="k", internal=False)
await tool_recall(engine, "bank-1", "query", ctx)
engine.recall_async.assert_called_once()
passed_ctx = engine.recall_async.call_args.kwargs["request_context"]
assert passed_ctx.internal is True, "sub-recall must be internal"
@pytest.mark.asyncio
async def test_recall_preserves_original_context(self):
engine = _mock_engine()
ctx = _FakeRequestContext(api_key="k", internal=False)
await tool_recall(engine, "bank-1", "query", ctx)
assert ctx.internal is False, "original context must not be mutated"
@@ -11,6 +11,7 @@ import pytest
from hindsight_api.engine.reflect.tools import tool_search_observations
from hindsight_api.engine.response_models import RecallResult
from hindsight_api.models import RequestContext
def _make_mock_engine(recall_result=None):
@@ -24,7 +25,11 @@ def _make_mock_engine(recall_result=None):
@pytest.fixture
def mock_request_context():
return MagicMock()
# Use a real dataclass instance — tool_search_observations calls
# dataclasses.replace(request_context, internal=True), which fails on
# MagicMock. The fields don't matter for these tests; we only inspect
# the kwargs passed to the mocked recall_async.
return RequestContext()
class TestSearchObservationsSourceFacts:
@@ -0,0 +1,117 @@
"""Unit tests for retain orchestrator mapping and embeddings length guarantee.
Regression coverage for issue #1037: a silent length mismatch between the
extracted facts and the generated embeddings caused
`_map_results_to_contents` to raise IndexError during batch_retain.
"""
from __future__ import annotations
import asyncio
from datetime import datetime
from unittest.mock import MagicMock
import pytest
from hindsight_api.engine.retain import embedding_utils
from hindsight_api.engine.retain.orchestrator import _map_results_to_contents
from hindsight_api.engine.retain.types import ProcessedFact, RetainContent
def _make_processed_fact(content_index: int, text: str = "fact") -> ProcessedFact:
return ProcessedFact(
fact_text=text,
fact_type="world",
embedding=[0.0, 0.0, 0.0],
occurred_start=None,
occurred_end=None,
mentioned_at=datetime(2026, 1, 1),
context="",
metadata={},
content_index=content_index,
)
def _make_content(text: str = "x") -> RetainContent:
return RetainContent(content=text)
class TestMapResultsToContents:
def test_groups_unit_ids_by_content_index(self):
contents = [_make_content("a"), _make_content("b"), _make_content("c")]
processed = [
_make_processed_fact(0, "a1"),
_make_processed_fact(0, "a2"),
_make_processed_fact(2, "c1"),
]
unit_ids = ["u-a1", "u-a2", "u-c1"]
result = _map_results_to_contents(contents, processed, unit_ids)
assert result == [["u-a1", "u-a2"], [], ["u-c1"]]
def test_handles_out_of_range_content_index(self):
contents = [_make_content("a"), _make_content("b")]
processed = [
_make_processed_fact(-1, "f1"),
_make_processed_fact(99, "f2"),
]
unit_ids = ["u1", "u2"]
result = _map_results_to_contents(contents, processed, unit_ids)
assert result == [["u1"], ["u2"]]
def test_empty_inputs(self):
assert _map_results_to_contents([], [], []) == []
def test_length_mismatch_raises(self):
# Regression for #1037: previously the function silently overran unit_ids.
contents = [_make_content("a")]
processed = [_make_processed_fact(0), _make_processed_fact(0)]
unit_ids = ["u1"] # one fewer than processed_facts
with pytest.raises(ValueError, match="length mismatch"):
_map_results_to_contents(contents, processed, unit_ids)
def test_unit_ids_assigned_by_processed_fact_position(self):
# Even if processed_facts are interleaved across contents, each unit_id
# must follow its corresponding processed_fact (positional alignment).
contents = [_make_content("a"), _make_content("b")]
processed = [
_make_processed_fact(1, "b1"),
_make_processed_fact(0, "a1"),
_make_processed_fact(1, "b2"),
]
unit_ids = ["u-b1", "u-a1", "u-b2"]
result = _map_results_to_contents(contents, processed, unit_ids)
assert result == [["u-a1"], ["u-b1", "u-b2"]]
class TestEmbeddingsBatchLengthGuarantee:
def test_raises_when_backend_returns_fewer_embeddings(self):
# Regression for #1037: backends that silently truncate must not pass
# through — `zip(extracted_facts, embeddings)` would otherwise drop
# facts and break unit_id alignment downstream.
backend = MagicMock()
backend.encode.return_value = [[0.1, 0.2]] # only 1 vector for 3 inputs
with pytest.raises(RuntimeError, match="returned 1 vectors for 3 input texts"):
asyncio.run(embedding_utils.generate_embeddings_batch(backend, ["a", "b", "c"]))
def test_raises_when_backend_returns_more_embeddings(self):
backend = MagicMock()
backend.encode.return_value = [[0.1], [0.2], [0.3]]
with pytest.raises(RuntimeError, match="returned 3 vectors for 2 input texts"):
asyncio.run(embedding_utils.generate_embeddings_batch(backend, ["a", "b"]))
def test_passes_through_aligned_embeddings(self):
backend = MagicMock()
backend.encode.return_value = [[0.1], [0.2]]
result = asyncio.run(embedding_utils.generate_embeddings_batch(backend, ["a", "b"]))
assert result == [[0.1], [0.2]]
+71
View File
@@ -0,0 +1,71 @@
"""Unit tests for the worker stage breadcrumb module."""
import asyncio
import pytest
from hindsight_api.worker.stage import StageHolder, bind_holder, get_stage, set_stage
def test_set_stage_is_noop_without_holder():
# No holder bound in this context: must not raise, and get_stage returns None.
set_stage("anything")
assert get_stage() is None
@pytest.mark.asyncio
async def test_holder_bound_inside_task_is_visible_to_called_code():
holder = StageHolder()
async def inner():
# The poller binds the holder from inside the task coroutine so it
# lives in that task's contextvar scope; mirror that here.
bind_holder(holder)
set_stage("phase1")
# Engine code further down the call stack reads via set_stage.
set_stage("phase2")
assert get_stage() == "phase2"
await asyncio.create_task(inner())
# Holder is mutable: the spawning context sees the latest stage written
# by the child task without needing access to the contextvar.
assert holder.stage == "phase2"
@pytest.mark.asyncio
async def test_holder_does_not_leak_across_tasks():
# Each asyncio.create_task copies the parent's context. Binding inside
# one task must not affect a sibling task's view.
holder_a = StageHolder()
holder_b = StageHolder()
async def task_a():
bind_holder(holder_a)
set_stage("a")
async def task_b():
bind_holder(holder_b)
set_stage("b")
await asyncio.gather(asyncio.create_task(task_a()), asyncio.create_task(task_b()))
assert holder_a.stage == "a"
assert holder_b.stage == "b"
# Outside both tasks, no holder is bound.
assert get_stage() is None
@pytest.mark.asyncio
async def test_set_stage_updates_timestamp():
holder = StageHolder()
async def inner():
bind_holder(holder)
first = holder.updated_at
# asyncio.sleep guarantees monotonic clock advances on next set.
await asyncio.sleep(0.01)
set_stage("next")
assert holder.updated_at > first
await asyncio.create_task(inner())
+191 -1
View File
@@ -217,6 +217,7 @@ class TestWorkerPoller:
worker_id="test-worker-1",
executor=lambda x: None,
max_slots=3, # Limit to 3 concurrent tasks
consolidation_max_slots=0, # No reservation; all 3 slots available for non-consolidation
)
claimed = await poller.claim_batch()
@@ -1363,7 +1364,7 @@ async def test_worker_slot_limits_enforced(pool, clean_operations):
executor=controlled_executor,
poll_interval_ms=50,
max_slots=3, # Only allow 3 concurrent tasks
consolidation_max_slots=1,
consolidation_max_slots=0, # No consolidation reservation; all 3 slots available for retain
)
# Submit 10 tasks
@@ -1428,6 +1429,195 @@ async def test_worker_slot_limits_enforced(pool, clean_operations):
pass
async def test_consolidation_slots_reserved_when_retain_saturates(pool, clean_operations):
"""Regression: consolidation must not be starved when retain saturates the queue.
With ``max_slots=5`` and ``consolidation_max_slots=2``, retain tasks may use at
most 3 concurrent slots, leaving 2 slots reserved for consolidation. Without
the reservation (issue #1006), a continuous stream of retain tasks would fill
every slot and consolidation would never run.
"""
from hindsight_api.worker.poller import WorkerPoller
started: dict[str, str] = {} # op_id -> op_type
finish_events: dict[str, asyncio.Event] = {}
async def blocking_executor(task_dict: dict):
op_id = task_dict["operation_id"]
started[op_id] = task_dict.get("operation_type", "unknown")
event = asyncio.Event()
finish_events[op_id] = event
await event.wait()
poller = WorkerPoller(
pool=pool,
worker_id="test-worker-consolidation-reservation",
executor=blocking_executor,
poll_interval_ms=50,
max_slots=5,
consolidation_max_slots=2,
)
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
await _ensure_bank(pool, bank_id)
# Submit 10 retain tasks first — these should be claimed up to the
# non-consolidation cap (max_slots - consolidation_max_slots = 3).
for _ in range(10):
op_id = uuid.uuid4()
payload = json.dumps(
{"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id}
)
await pool.execute(
"""
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
VALUES ($1, $2, 'retain', 'pending', $3::jsonb)
""",
op_id,
bank_id,
payload,
)
# Submit 1 consolidation task. Note the payload deliberately omits operation_type
# to verify the poller injects it from the DB column.
consolidation_op_id = uuid.uuid4()
consolidation_payload = json.dumps({"type": "test", "operation_id": str(consolidation_op_id), "bank_id": bank_id})
await pool.execute(
"""
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
VALUES ($1, $2, 'consolidation', 'pending', $3::jsonb)
""",
consolidation_op_id,
bank_id,
consolidation_payload,
)
poll_task = asyncio.create_task(poller.run())
try:
# Wait for the worker to fill its slots: 3 retain + 1 consolidation = 4 active.
for _ in range(200):
if len(started) >= 4:
break
await asyncio.sleep(0.01)
retain_started = [op for op, t in started.items() if t == "retain"]
consolidation_started = [op for op, t in started.items() if t == "consolidation"]
assert len(retain_started) == 3, (
f"Retain should be capped at max_slots - consolidation_max_slots = 3, got {len(retain_started)}"
)
assert len(consolidation_started) == 1, (
f"Consolidation should claim its reserved slot even while retain saturates, "
f"got {len(consolidation_started)}"
)
assert str(consolidation_op_id) in consolidation_started
# In-flight tracking must record the consolidation task under the right key,
# otherwise the consolidation pool accounting drifts on subsequent claims.
async with poller._in_flight_lock:
assert poller._in_flight_by_type.get("consolidation", 0) == 1
finally:
for event in finish_events.values():
event.set()
await poller.shutdown_graceful(timeout=2.0)
try:
await asyncio.wait_for(poll_task, timeout=1.0)
except asyncio.CancelledError:
pass
@pytest.mark.asyncio
async def test_pending_breakdown_explains_unclaimable_rows(pool, clean_operations, caplog):
"""Pending rows that the claim query filters out must be visible in logs.
Background: production incident where a 'pending' retain sat in the queue for
hours while workers had free slots. With only the global pending count in
[WORKER_STATS] there's no way to tell whether the rows are claimable-but-not-
being-claimed (real bug) vs filtered out by the claim WHERE clause (data
state). This test verifies [PENDING_BREAKDOWN] surfaces each filter bucket
so operators can diagnose without DB access.
"""
import logging
from hindsight_api.worker.poller import WorkerPoller
poller = WorkerPoller(
pool=pool,
worker_id="test-worker-pending-breakdown",
executor=lambda _t: asyncio.sleep(0),
poll_interval_ms=50,
max_slots=5,
)
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
await _ensure_bank(pool, bank_id)
# Mix of pending rows that the claim query treats differently:
# * payload_null - batch_retain parent (orphan candidate)
# * retry_blocked - failed once, scheduled an hour out
# * assigned - worker_id stamped (e.g. left over from a prior crash
# that re-queued without clearing worker_id)
# * claimable - normal retain ready to go
# * consolidation - normal consolidation, also claimable
rows = [
("batch_retain", None, None, None), # payload_null
("retain", json.dumps({"type": "test"}), "future", None), # retry_blocked
("retain", json.dumps({"type": "test"}), None, "ghost-worker"), # assigned
("retain", json.dumps({"type": "test"}), None, None), # claimable
("consolidation", json.dumps({"type": "test"}), None, None), # claimable
]
for op_type, payload, retry_marker, worker_id in rows:
op_id = uuid.uuid4()
await pool.execute(
"""
INSERT INTO async_operations
(operation_id, bank_id, operation_type, status, task_payload,
next_retry_at, worker_id)
VALUES ($1, $2, $3, 'pending', $4::jsonb,
CASE WHEN $5::text = 'future' THEN now() + interval '1 hour' ELSE NULL END,
$6)
""",
op_id,
bank_id,
op_type,
payload,
retry_marker,
worker_id,
)
# Trigger one stats emit. _last_progress_log starts at 0, so the first call
# always logs.
with caplog.at_level(logging.INFO, logger="hindsight_api.worker.poller"):
await poller._log_progress_if_due()
breakdown_lines = [r.message for r in caplog.records if r.message.startswith("[PENDING_BREAKDOWN]")]
assert len(breakdown_lines) == 1, f"Expected exactly one breakdown line, got: {breakdown_lines}"
# The breakdown is global (not bank-scoped), so other rows in the table may
# contribute. Parse the per-op_type buckets from the line and assert that
# our additions appear (>= 1 for each bucket we populated).
line = breakdown_lines[0]
buckets: dict[str, dict[str, int]] = {}
for section in line.removeprefix("[PENDING_BREAKDOWN]").split("|"):
section = section.strip()
if ":" not in section:
continue
op_type, fields = section.split(":", 1)
kv = {}
for token in fields.strip().split():
k, _, v = token.partition("=")
kv[k] = int(v)
buckets[op_type.strip()] = kv
assert buckets["batch_retain"]["payload_null"] >= 1
assert buckets["retain"]["retry_blocked"] >= 1
assert buckets["retain"]["assigned"] >= 1
assert buckets["retain"]["claimable"] >= 1
assert buckets["consolidation"]["claimable"] >= 1
class TestMarkFailedParentPropagation:
"""Tests for _mark_failed parent propagation in WorkerPoller.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-api"
version = "0.5.0"
version = "0.5.1"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.5.0"
version = "0.5.1"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
+46 -5
View File
@@ -5,11 +5,43 @@
use anyhow::Result;
pub use hindsight_client::types;
use hindsight_client::Client as AsyncClient;
use hindsight_client::{Client as AsyncClient, Error as ClientError};
use serde::{Deserialize, Serialize};
use serde_json;
use std::collections::HashMap;
/// Convert a progenitor client error into an anyhow error that includes the
/// HTTP response body. Without this, errors render as
/// "Unexpected Response: Response { ... }" with no body, hiding validation
/// details (see issue #1007).
async fn humanize_client_error<E>(err: ClientError<E>) -> anyhow::Error
where
E: serde::Serialize + std::fmt::Debug + Send + Sync + 'static,
{
match err {
ClientError::ErrorResponse(rv) => {
let status = rv.status();
let body = rv.into_inner();
let body_str = serde_json::to_string(&body).unwrap_or_else(|_| format!("{:?}", body));
anyhow::anyhow!("API request failed ({}): {}", status, body_str)
}
ClientError::UnexpectedResponse(response) => {
let status = response.status();
let body = response.text().await.unwrap_or_default();
if body.is_empty() {
anyhow::anyhow!("API request failed ({})", status)
} else {
anyhow::anyhow!("API request failed ({}): {}", status, body)
}
}
ClientError::InvalidResponsePayload(bytes, src) => {
let body = String::from_utf8_lossy(&bytes);
anyhow::anyhow!("Invalid response payload ({}): {}", src, body)
}
other => anyhow::anyhow!("{}", other),
}
}
// Types not defined in OpenAPI spec (TODO: add to openapi.json)
#[derive(Debug, Serialize, Deserialize)]
pub struct AgentStats {
@@ -184,7 +216,10 @@ impl ApiClient {
);
}
self.runtime.block_on(async {
let response = self.client.recall_memories(agent_id, None, request).await?;
let response = match self.client.recall_memories(agent_id, None, request).await {
Ok(r) => r,
Err(e) => return Err(humanize_client_error(e).await),
};
Ok(response.into_inner())
})
}
@@ -196,7 +231,10 @@ impl ApiClient {
_verbose: bool,
) -> Result<types::ReflectResponse> {
self.runtime.block_on(async {
let response = self.client.reflect(agent_id, None, request).await?;
let response = match self.client.reflect(agent_id, None, request).await {
Ok(r) => r,
Err(e) => return Err(humanize_client_error(e).await),
};
Ok(response.into_inner())
})
}
@@ -209,7 +247,10 @@ impl ApiClient {
_verbose: bool,
) -> Result<MemoryPutResult> {
self.runtime.block_on(async {
let response = self.client.retain_memories(agent_id, None, request).await?;
let response = match self.client.retain_memories(agent_id, None, request).await {
Ok(r) => r,
Err(e) => return Err(humanize_client_error(e).await),
};
let result = response.into_inner();
Ok(MemoryPutResult {
success: result.success,
@@ -694,7 +735,7 @@ impl ApiClient {
self.runtime.block_on(async {
let response = self
.client
.get_operation_status(bank_id, operation_id, None)
.get_operation_status(bank_id, operation_id, None, None)
.await?;
Ok(response.into_inner())
})
+4 -2
View File
@@ -21,7 +21,7 @@ use serde_json;
struct MemoryUnitDetail {
id: String,
text: String,
#[serde(rename = "type")]
#[serde(rename = "fact_type")]
type_: Option<String>,
document_id: Option<String>,
context: Option<String>,
@@ -106,13 +106,14 @@ pub fn list(
} else {
for item in &result.items {
let fact_type = item
.get("type")
.get("fact_type")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let type_t = match fact_type {
"world" => 0.0,
"experience" => 0.5,
"opinion" => 1.0,
"observation" => 0.25,
_ => 0.5,
};
@@ -179,6 +180,7 @@ pub fn get(
"world" => 0.0,
"experience" => 0.5,
"opinion" => 1.0,
"observation" => 0.25,
_ => 0.5,
};
@@ -124,6 +124,9 @@ pub fn create(
fact_types: None,
tag_groups: None,
tags_match: None,
include_chunks: None,
recall_max_tokens: None,
recall_chunks_max_tokens: None,
})
} else {
None
@@ -198,6 +201,9 @@ pub fn update(
fact_types: None,
tag_groups: None,
tags_match: None,
include_chunks: None,
recall_max_tokens: None,
recall_chunks_max_tokens: None,
});
let request = types::UpdateMentalModelRequest {
+2
View File
@@ -84,6 +84,8 @@ pub fn print_fact(fact: &RecallResult, _show_activation: bool) {
let type_t = match fact_type {
"world" => 0.0,
"agent" => 0.5,
"experience" => 0.5,
"observation" => 0.25,
"opinion" => 1.0,
_ => 0.5,
};
+80 -1
View File
@@ -7,7 +7,7 @@ info:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
title: Hindsight HTTP API
version: 0.5.0
version: 0.5.1
servers:
- url: /
paths:
@@ -1781,6 +1781,19 @@ paths:
title: Operation Id
type: string
style: simple
- description: Include the raw task payload (submission params) in the response.
May be large.
explode: true
in: query
name: include_payload
required: false
schema:
default: false
description: Include the raw task payload (submission params) in the response.
May be large.
title: Include Payload
type: boolean
style: form
- explode: false
in: header
name: authorization
@@ -3576,6 +3589,39 @@ components:
entities_allow_free_form:
nullable: true
type: boolean
retain_default_strategy:
nullable: true
type: string
retain_strategies:
additionalProperties: {}
nullable: true
retain_chunk_batch_size:
nullable: true
type: integer
mcp_enabled_tools:
items:
type: string
nullable: true
type: array
consolidation_llm_batch_size:
nullable: true
type: integer
consolidation_source_facts_max_tokens:
nullable: true
type: integer
consolidation_source_facts_max_tokens_per_observation:
nullable: true
type: integer
max_observations_per_scope:
nullable: true
type: integer
reflect_source_facts_max_tokens:
nullable: true
type: integer
llm_gemini_safety_settings:
items: {}
nullable: true
type: array
title: BankTemplateConfig
BankTemplateDirective:
description: |-
@@ -4807,6 +4853,7 @@ components:
id: id
trigger:
refresh_after_consolidation: false
recall_chunks_max_tokens: 1
tag_groups:
- match: any_strict
tags:
@@ -4822,8 +4869,10 @@ components:
exclude_mental_model_ids:
- exclude_mental_model_ids
- exclude_mental_model_ids
include_chunks: true
tags_match: any
exclude_mental_models: false
recall_max_tokens: 6
last_refreshed_at: last_refreshed_at
content: content
tags:
@@ -4839,6 +4888,7 @@ components:
id: id
trigger:
refresh_after_consolidation: false
recall_chunks_max_tokens: 1
tag_groups:
- match: any_strict
tags:
@@ -4854,8 +4904,10 @@ components:
exclude_mental_model_ids:
- exclude_mental_model_ids
- exclude_mental_model_ids
include_chunks: true
tags_match: any
exclude_mental_models: false
recall_max_tokens: 6
last_refreshed_at: last_refreshed_at
content: content
tags:
@@ -4882,6 +4934,7 @@ components:
id: id
trigger:
refresh_after_consolidation: false
recall_chunks_max_tokens: 1
tag_groups:
- match: any_strict
tags:
@@ -4897,8 +4950,10 @@ components:
exclude_mental_model_ids:
- exclude_mental_model_ids
- exclude_mental_model_ids
include_chunks: true
tags_match: any
exclude_mental_models: false
recall_max_tokens: 6
last_refreshed_at: last_refreshed_at
content: content
tags:
@@ -4986,11 +5041,21 @@ components:
$ref: '#/components/schemas/MentalModelTrigger_Input_tag_groups_inner'
nullable: true
type: array
include_chunks:
nullable: true
type: boolean
recall_max_tokens:
nullable: true
type: integer
recall_chunks_max_tokens:
nullable: true
type: integer
title: MentalModelTrigger
MentalModelTrigger-Output:
description: Trigger settings for a mental model.
example:
refresh_after_consolidation: false
recall_chunks_max_tokens: 1
tag_groups:
- match: any_strict
tags:
@@ -5006,8 +5071,10 @@ components:
exclude_mental_model_ids:
- exclude_mental_model_ids
- exclude_mental_model_ids
include_chunks: true
tags_match: any
exclude_mental_models: false
recall_max_tokens: 6
properties:
refresh_after_consolidation:
default: false
@@ -5048,6 +5115,15 @@ components:
$ref: '#/components/schemas/MentalModelTrigger_Output_tag_groups_inner'
nullable: true
type: array
include_chunks:
nullable: true
type: boolean
recall_max_tokens:
nullable: true
type: integer
recall_chunks_max_tokens:
nullable: true
type: integer
title: MentalModelTrigger
OperationResponse:
description: Response model for a single async operation.
@@ -5131,6 +5207,9 @@ components:
$ref: '#/components/schemas/ChildOperationStatus'
nullable: true
type: array
task_payload:
additionalProperties: {}
nullable: true
required:
- operation_id
- status
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// 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.5.0
API version: 0.5.1
*/
// 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.5.0
API version: 0.5.1
*/
// 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.5.0
API version: 0.5.1
*/
// 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.5.0
API version: 0.5.1
*/
// 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.5.0
API version: 0.5.1
*/
// 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.5.0
API version: 0.5.1
*/
// 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.5.0
API version: 0.5.1
*/
// 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.5.0
API version: 0.5.1
*/
// 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.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+14 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -154,9 +154,16 @@ type ApiGetOperationStatusRequest struct {
ApiService *OperationsAPIService
bankId string
operationId string
includePayload *bool
authorization *string
}
// Include the raw task payload (submission params) in the response. May be large.
func (r ApiGetOperationStatusRequest) IncludePayload(includePayload bool) ApiGetOperationStatusRequest {
r.includePayload = &includePayload
return r
}
func (r ApiGetOperationStatusRequest) Authorization(authorization string) ApiGetOperationStatusRequest {
r.authorization = &authorization
return r
@@ -208,6 +215,12 @@ func (a *OperationsAPIService) GetOperationStatusExecute(r ApiGetOperationStatus
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
if r.includePayload != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "include_payload", r.includePayload, "form", "")
} else {
var defaultValue bool = false
r.includePayload = &defaultValue
}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+2 -2
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -41,7 +41,7 @@ var (
queryDescape = strings.NewReplacer( "%5B", "[", "%5D", "]" )
)
// APIClient manages communication with the Hindsight HTTP API API v0.5.0
// APIClient manages communication with the Hindsight HTTP API API v0.5.1
// In most cases there should be only one, shared, APIClient.
type APIClient struct {
cfg *Configuration
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+24
View File
@@ -2,9 +2,31 @@ package hindsight
import (
"net/http"
"runtime/debug"
"time"
)
// defaultUserAgent returns the User-Agent string sent on every request unless
// the caller overrides cfg.UserAgent. The version is read from build info so
// it stays in sync with the module version automatically; falls back to
// "devel" when running from an unpinned local checkout.
func defaultUserAgent() string {
version := "devel"
if info, ok := debug.ReadBuildInfo(); ok {
for _, dep := range info.Deps {
if dep.Path == "github.com/vectorize-io/hindsight/hindsight-clients/go" {
version = dep.Version
break
}
}
}
return "hindsight-client-go/" + version
}
// DefaultUserAgent is the User-Agent string sent on every request unless the
// caller overrides cfg.UserAgent (e.g. for integrations identifying themselves).
var DefaultUserAgent = defaultUserAgent()
// NewAPIClientWithToken creates a new API client configured with a base URL and API token.
// The token is sent as a Bearer token in the Authorization header for all requests.
// Note: this uses http.DefaultClient which has no timeout. Use NewAPIClientWithTimeout
@@ -16,6 +38,7 @@ import (
// resp, _, err := client.MemoryAPI.RetainMemories(ctx, bankID).RetainRequest(req).Execute()
func NewAPIClientWithToken(baseURL, token string) *APIClient {
cfg := NewConfiguration()
cfg.UserAgent = DefaultUserAgent
cfg.Servers = ServerConfigurations{
{URL: baseURL},
}
@@ -32,6 +55,7 @@ func NewAPIClientWithToken(baseURL, token string) *APIClient {
// resp, _, err := client.MemoryAPI.RetainMemories(ctx, bankID).RetainRequest(req).Execute()
func NewAPIClientWithTimeout(baseURL, token string, timeout time.Duration) *APIClient {
cfg := NewConfiguration()
cfg.UserAgent = DefaultUserAgent
cfg.Servers = ServerConfigurations{
{URL: baseURL},
}
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// 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.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -31,6 +31,16 @@ type BankTemplateConfig struct {
DispositionEmpathy NullableInt32 `json:"disposition_empathy,omitempty"`
EntityLabels []map[string]interface{} `json:"entity_labels,omitempty"`
EntitiesAllowFreeForm NullableBool `json:"entities_allow_free_form,omitempty"`
RetainDefaultStrategy NullableString `json:"retain_default_strategy,omitempty"`
RetainStrategies map[string]interface{} `json:"retain_strategies,omitempty"`
RetainChunkBatchSize NullableInt32 `json:"retain_chunk_batch_size,omitempty"`
McpEnabledTools []string `json:"mcp_enabled_tools,omitempty"`
ConsolidationLlmBatchSize NullableInt32 `json:"consolidation_llm_batch_size,omitempty"`
ConsolidationSourceFactsMaxTokens NullableInt32 `json:"consolidation_source_facts_max_tokens,omitempty"`
ConsolidationSourceFactsMaxTokensPerObservation NullableInt32 `json:"consolidation_source_facts_max_tokens_per_observation,omitempty"`
MaxObservationsPerScope NullableInt32 `json:"max_observations_per_scope,omitempty"`
ReflectSourceFactsMaxTokens NullableInt32 `json:"reflect_source_facts_max_tokens,omitempty"`
LlmGeminiSafetySettings []interface{} `json:"llm_gemini_safety_settings,omitempty"`
}
// NewBankTemplateConfig instantiates a new BankTemplateConfig object
@@ -545,6 +555,399 @@ func (o *BankTemplateConfig) UnsetEntitiesAllowFreeForm() {
o.EntitiesAllowFreeForm.Unset()
}
// GetRetainDefaultStrategy returns the RetainDefaultStrategy field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetRetainDefaultStrategy() string {
if o == nil || IsNil(o.RetainDefaultStrategy.Get()) {
var ret string
return ret
}
return *o.RetainDefaultStrategy.Get()
}
// GetRetainDefaultStrategyOk returns a tuple with the RetainDefaultStrategy field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetRetainDefaultStrategyOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.RetainDefaultStrategy.Get(), o.RetainDefaultStrategy.IsSet()
}
// HasRetainDefaultStrategy returns a boolean if a field has been set.
func (o *BankTemplateConfig) HasRetainDefaultStrategy() bool {
if o != nil && o.RetainDefaultStrategy.IsSet() {
return true
}
return false
}
// SetRetainDefaultStrategy gets a reference to the given NullableString and assigns it to the RetainDefaultStrategy field.
func (o *BankTemplateConfig) SetRetainDefaultStrategy(v string) {
o.RetainDefaultStrategy.Set(&v)
}
// SetRetainDefaultStrategyNil sets the value for RetainDefaultStrategy to be an explicit nil
func (o *BankTemplateConfig) SetRetainDefaultStrategyNil() {
o.RetainDefaultStrategy.Set(nil)
}
// UnsetRetainDefaultStrategy ensures that no value is present for RetainDefaultStrategy, not even an explicit nil
func (o *BankTemplateConfig) UnsetRetainDefaultStrategy() {
o.RetainDefaultStrategy.Unset()
}
// GetRetainStrategies returns the RetainStrategies field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetRetainStrategies() map[string]interface{} {
if o == nil {
var ret map[string]interface{}
return ret
}
return o.RetainStrategies
}
// GetRetainStrategiesOk returns a tuple with the RetainStrategies field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetRetainStrategiesOk() (map[string]interface{}, bool) {
if o == nil || IsNil(o.RetainStrategies) {
return map[string]interface{}{}, false
}
return o.RetainStrategies, true
}
// HasRetainStrategies returns a boolean if a field has been set.
func (o *BankTemplateConfig) HasRetainStrategies() bool {
if o != nil && !IsNil(o.RetainStrategies) {
return true
}
return false
}
// SetRetainStrategies gets a reference to the given map[string]interface{} and assigns it to the RetainStrategies field.
func (o *BankTemplateConfig) SetRetainStrategies(v map[string]interface{}) {
o.RetainStrategies = v
}
// GetRetainChunkBatchSize returns the RetainChunkBatchSize field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetRetainChunkBatchSize() int32 {
if o == nil || IsNil(o.RetainChunkBatchSize.Get()) {
var ret int32
return ret
}
return *o.RetainChunkBatchSize.Get()
}
// GetRetainChunkBatchSizeOk returns a tuple with the RetainChunkBatchSize field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetRetainChunkBatchSizeOk() (*int32, bool) {
if o == nil {
return nil, false
}
return o.RetainChunkBatchSize.Get(), o.RetainChunkBatchSize.IsSet()
}
// HasRetainChunkBatchSize returns a boolean if a field has been set.
func (o *BankTemplateConfig) HasRetainChunkBatchSize() bool {
if o != nil && o.RetainChunkBatchSize.IsSet() {
return true
}
return false
}
// SetRetainChunkBatchSize gets a reference to the given NullableInt32 and assigns it to the RetainChunkBatchSize field.
func (o *BankTemplateConfig) SetRetainChunkBatchSize(v int32) {
o.RetainChunkBatchSize.Set(&v)
}
// SetRetainChunkBatchSizeNil sets the value for RetainChunkBatchSize to be an explicit nil
func (o *BankTemplateConfig) SetRetainChunkBatchSizeNil() {
o.RetainChunkBatchSize.Set(nil)
}
// UnsetRetainChunkBatchSize ensures that no value is present for RetainChunkBatchSize, not even an explicit nil
func (o *BankTemplateConfig) UnsetRetainChunkBatchSize() {
o.RetainChunkBatchSize.Unset()
}
// GetMcpEnabledTools returns the McpEnabledTools field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetMcpEnabledTools() []string {
if o == nil {
var ret []string
return ret
}
return o.McpEnabledTools
}
// GetMcpEnabledToolsOk returns a tuple with the McpEnabledTools field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetMcpEnabledToolsOk() ([]string, bool) {
if o == nil || IsNil(o.McpEnabledTools) {
return nil, false
}
return o.McpEnabledTools, true
}
// HasMcpEnabledTools returns a boolean if a field has been set.
func (o *BankTemplateConfig) HasMcpEnabledTools() bool {
if o != nil && !IsNil(o.McpEnabledTools) {
return true
}
return false
}
// SetMcpEnabledTools gets a reference to the given []string and assigns it to the McpEnabledTools field.
func (o *BankTemplateConfig) SetMcpEnabledTools(v []string) {
o.McpEnabledTools = v
}
// GetConsolidationLlmBatchSize returns the ConsolidationLlmBatchSize field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetConsolidationLlmBatchSize() int32 {
if o == nil || IsNil(o.ConsolidationLlmBatchSize.Get()) {
var ret int32
return ret
}
return *o.ConsolidationLlmBatchSize.Get()
}
// GetConsolidationLlmBatchSizeOk returns a tuple with the ConsolidationLlmBatchSize field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetConsolidationLlmBatchSizeOk() (*int32, bool) {
if o == nil {
return nil, false
}
return o.ConsolidationLlmBatchSize.Get(), o.ConsolidationLlmBatchSize.IsSet()
}
// HasConsolidationLlmBatchSize returns a boolean if a field has been set.
func (o *BankTemplateConfig) HasConsolidationLlmBatchSize() bool {
if o != nil && o.ConsolidationLlmBatchSize.IsSet() {
return true
}
return false
}
// SetConsolidationLlmBatchSize gets a reference to the given NullableInt32 and assigns it to the ConsolidationLlmBatchSize field.
func (o *BankTemplateConfig) SetConsolidationLlmBatchSize(v int32) {
o.ConsolidationLlmBatchSize.Set(&v)
}
// SetConsolidationLlmBatchSizeNil sets the value for ConsolidationLlmBatchSize to be an explicit nil
func (o *BankTemplateConfig) SetConsolidationLlmBatchSizeNil() {
o.ConsolidationLlmBatchSize.Set(nil)
}
// UnsetConsolidationLlmBatchSize ensures that no value is present for ConsolidationLlmBatchSize, not even an explicit nil
func (o *BankTemplateConfig) UnsetConsolidationLlmBatchSize() {
o.ConsolidationLlmBatchSize.Unset()
}
// GetConsolidationSourceFactsMaxTokens returns the ConsolidationSourceFactsMaxTokens field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetConsolidationSourceFactsMaxTokens() int32 {
if o == nil || IsNil(o.ConsolidationSourceFactsMaxTokens.Get()) {
var ret int32
return ret
}
return *o.ConsolidationSourceFactsMaxTokens.Get()
}
// GetConsolidationSourceFactsMaxTokensOk returns a tuple with the ConsolidationSourceFactsMaxTokens field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetConsolidationSourceFactsMaxTokensOk() (*int32, bool) {
if o == nil {
return nil, false
}
return o.ConsolidationSourceFactsMaxTokens.Get(), o.ConsolidationSourceFactsMaxTokens.IsSet()
}
// HasConsolidationSourceFactsMaxTokens returns a boolean if a field has been set.
func (o *BankTemplateConfig) HasConsolidationSourceFactsMaxTokens() bool {
if o != nil && o.ConsolidationSourceFactsMaxTokens.IsSet() {
return true
}
return false
}
// SetConsolidationSourceFactsMaxTokens gets a reference to the given NullableInt32 and assigns it to the ConsolidationSourceFactsMaxTokens field.
func (o *BankTemplateConfig) SetConsolidationSourceFactsMaxTokens(v int32) {
o.ConsolidationSourceFactsMaxTokens.Set(&v)
}
// SetConsolidationSourceFactsMaxTokensNil sets the value for ConsolidationSourceFactsMaxTokens to be an explicit nil
func (o *BankTemplateConfig) SetConsolidationSourceFactsMaxTokensNil() {
o.ConsolidationSourceFactsMaxTokens.Set(nil)
}
// UnsetConsolidationSourceFactsMaxTokens ensures that no value is present for ConsolidationSourceFactsMaxTokens, not even an explicit nil
func (o *BankTemplateConfig) UnsetConsolidationSourceFactsMaxTokens() {
o.ConsolidationSourceFactsMaxTokens.Unset()
}
// GetConsolidationSourceFactsMaxTokensPerObservation returns the ConsolidationSourceFactsMaxTokensPerObservation field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetConsolidationSourceFactsMaxTokensPerObservation() int32 {
if o == nil || IsNil(o.ConsolidationSourceFactsMaxTokensPerObservation.Get()) {
var ret int32
return ret
}
return *o.ConsolidationSourceFactsMaxTokensPerObservation.Get()
}
// GetConsolidationSourceFactsMaxTokensPerObservationOk returns a tuple with the ConsolidationSourceFactsMaxTokensPerObservation field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetConsolidationSourceFactsMaxTokensPerObservationOk() (*int32, bool) {
if o == nil {
return nil, false
}
return o.ConsolidationSourceFactsMaxTokensPerObservation.Get(), o.ConsolidationSourceFactsMaxTokensPerObservation.IsSet()
}
// HasConsolidationSourceFactsMaxTokensPerObservation returns a boolean if a field has been set.
func (o *BankTemplateConfig) HasConsolidationSourceFactsMaxTokensPerObservation() bool {
if o != nil && o.ConsolidationSourceFactsMaxTokensPerObservation.IsSet() {
return true
}
return false
}
// SetConsolidationSourceFactsMaxTokensPerObservation gets a reference to the given NullableInt32 and assigns it to the ConsolidationSourceFactsMaxTokensPerObservation field.
func (o *BankTemplateConfig) SetConsolidationSourceFactsMaxTokensPerObservation(v int32) {
o.ConsolidationSourceFactsMaxTokensPerObservation.Set(&v)
}
// SetConsolidationSourceFactsMaxTokensPerObservationNil sets the value for ConsolidationSourceFactsMaxTokensPerObservation to be an explicit nil
func (o *BankTemplateConfig) SetConsolidationSourceFactsMaxTokensPerObservationNil() {
o.ConsolidationSourceFactsMaxTokensPerObservation.Set(nil)
}
// UnsetConsolidationSourceFactsMaxTokensPerObservation ensures that no value is present for ConsolidationSourceFactsMaxTokensPerObservation, not even an explicit nil
func (o *BankTemplateConfig) UnsetConsolidationSourceFactsMaxTokensPerObservation() {
o.ConsolidationSourceFactsMaxTokensPerObservation.Unset()
}
// GetMaxObservationsPerScope returns the MaxObservationsPerScope field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetMaxObservationsPerScope() int32 {
if o == nil || IsNil(o.MaxObservationsPerScope.Get()) {
var ret int32
return ret
}
return *o.MaxObservationsPerScope.Get()
}
// GetMaxObservationsPerScopeOk returns a tuple with the MaxObservationsPerScope field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetMaxObservationsPerScopeOk() (*int32, bool) {
if o == nil {
return nil, false
}
return o.MaxObservationsPerScope.Get(), o.MaxObservationsPerScope.IsSet()
}
// HasMaxObservationsPerScope returns a boolean if a field has been set.
func (o *BankTemplateConfig) HasMaxObservationsPerScope() bool {
if o != nil && o.MaxObservationsPerScope.IsSet() {
return true
}
return false
}
// SetMaxObservationsPerScope gets a reference to the given NullableInt32 and assigns it to the MaxObservationsPerScope field.
func (o *BankTemplateConfig) SetMaxObservationsPerScope(v int32) {
o.MaxObservationsPerScope.Set(&v)
}
// SetMaxObservationsPerScopeNil sets the value for MaxObservationsPerScope to be an explicit nil
func (o *BankTemplateConfig) SetMaxObservationsPerScopeNil() {
o.MaxObservationsPerScope.Set(nil)
}
// UnsetMaxObservationsPerScope ensures that no value is present for MaxObservationsPerScope, not even an explicit nil
func (o *BankTemplateConfig) UnsetMaxObservationsPerScope() {
o.MaxObservationsPerScope.Unset()
}
// GetReflectSourceFactsMaxTokens returns the ReflectSourceFactsMaxTokens field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetReflectSourceFactsMaxTokens() int32 {
if o == nil || IsNil(o.ReflectSourceFactsMaxTokens.Get()) {
var ret int32
return ret
}
return *o.ReflectSourceFactsMaxTokens.Get()
}
// GetReflectSourceFactsMaxTokensOk returns a tuple with the ReflectSourceFactsMaxTokens field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetReflectSourceFactsMaxTokensOk() (*int32, bool) {
if o == nil {
return nil, false
}
return o.ReflectSourceFactsMaxTokens.Get(), o.ReflectSourceFactsMaxTokens.IsSet()
}
// HasReflectSourceFactsMaxTokens returns a boolean if a field has been set.
func (o *BankTemplateConfig) HasReflectSourceFactsMaxTokens() bool {
if o != nil && o.ReflectSourceFactsMaxTokens.IsSet() {
return true
}
return false
}
// SetReflectSourceFactsMaxTokens gets a reference to the given NullableInt32 and assigns it to the ReflectSourceFactsMaxTokens field.
func (o *BankTemplateConfig) SetReflectSourceFactsMaxTokens(v int32) {
o.ReflectSourceFactsMaxTokens.Set(&v)
}
// SetReflectSourceFactsMaxTokensNil sets the value for ReflectSourceFactsMaxTokens to be an explicit nil
func (o *BankTemplateConfig) SetReflectSourceFactsMaxTokensNil() {
o.ReflectSourceFactsMaxTokens.Set(nil)
}
// UnsetReflectSourceFactsMaxTokens ensures that no value is present for ReflectSourceFactsMaxTokens, not even an explicit nil
func (o *BankTemplateConfig) UnsetReflectSourceFactsMaxTokens() {
o.ReflectSourceFactsMaxTokens.Unset()
}
// GetLlmGeminiSafetySettings returns the LlmGeminiSafetySettings field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *BankTemplateConfig) GetLlmGeminiSafetySettings() []interface{} {
if o == nil {
var ret []interface{}
return ret
}
return o.LlmGeminiSafetySettings
}
// GetLlmGeminiSafetySettingsOk returns a tuple with the LlmGeminiSafetySettings field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *BankTemplateConfig) GetLlmGeminiSafetySettingsOk() ([]interface{}, bool) {
if o == nil || IsNil(o.LlmGeminiSafetySettings) {
return nil, false
}
return o.LlmGeminiSafetySettings, true
}
// HasLlmGeminiSafetySettings returns a boolean if a field has been set.
func (o *BankTemplateConfig) HasLlmGeminiSafetySettings() bool {
if o != nil && !IsNil(o.LlmGeminiSafetySettings) {
return true
}
return false
}
// SetLlmGeminiSafetySettings gets a reference to the given []interface{} and assigns it to the LlmGeminiSafetySettings field.
func (o *BankTemplateConfig) SetLlmGeminiSafetySettings(v []interface{}) {
o.LlmGeminiSafetySettings = v
}
func (o BankTemplateConfig) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
@@ -591,6 +994,36 @@ func (o BankTemplateConfig) ToMap() (map[string]interface{}, error) {
if o.EntitiesAllowFreeForm.IsSet() {
toSerialize["entities_allow_free_form"] = o.EntitiesAllowFreeForm.Get()
}
if o.RetainDefaultStrategy.IsSet() {
toSerialize["retain_default_strategy"] = o.RetainDefaultStrategy.Get()
}
if o.RetainStrategies != nil {
toSerialize["retain_strategies"] = o.RetainStrategies
}
if o.RetainChunkBatchSize.IsSet() {
toSerialize["retain_chunk_batch_size"] = o.RetainChunkBatchSize.Get()
}
if o.McpEnabledTools != nil {
toSerialize["mcp_enabled_tools"] = o.McpEnabledTools
}
if o.ConsolidationLlmBatchSize.IsSet() {
toSerialize["consolidation_llm_batch_size"] = o.ConsolidationLlmBatchSize.Get()
}
if o.ConsolidationSourceFactsMaxTokens.IsSet() {
toSerialize["consolidation_source_facts_max_tokens"] = o.ConsolidationSourceFactsMaxTokens.Get()
}
if o.ConsolidationSourceFactsMaxTokensPerObservation.IsSet() {
toSerialize["consolidation_source_facts_max_tokens_per_observation"] = o.ConsolidationSourceFactsMaxTokensPerObservation.Get()
}
if o.MaxObservationsPerScope.IsSet() {
toSerialize["max_observations_per_scope"] = o.MaxObservationsPerScope.Get()
}
if o.ReflectSourceFactsMaxTokens.IsSet() {
toSerialize["reflect_source_facts_max_tokens"] = o.ReflectSourceFactsMaxTokens.Get()
}
if o.LlmGeminiSafetySettings != nil {
toSerialize["llm_gemini_safety_settings"] = o.LlmGeminiSafetySettings
}
return toSerialize, nil
}
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// 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.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// 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.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// 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.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.5.0
API version: 0.5.1
*/
// 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