Compare commits

..
Author SHA1 Message Date
Ben 45395e2b51 Merge branch 'main' into feat/haystack-integration 2026-06-08 14:39:17 -04:00
Ben 1a200a9cd6 fix(haystack): use official Haystack logo for gallery icon
Replace the placeholder glyph with the real deepset Haystack mark (teal
#0EAF9C rounded square + white symbol), extracted as vector from deepset's
own website source (deepset-ai/haystack-home site-logo partial).
2026-06-08 14:35:04 -04:00
Ben 6df79e329d fix(haystack): register in changelog/gallery + docs page + tidy tools
Review follow-ups for the Haystack integration:

1. Add haystack to the INTEGRATIONS map in generate_changelog.py so the
   release script's changelog step resolves the slug (was missing, which
   would fail the release).
2. Add the integrations.json gallery entry, a doc page at
   docs-integrations/haystack.md, and an icon — required by
   check-integrations.mjs (forward: entry needs a doc page; reverse: a
   released integration must appear in the gallery).
3. Drop the inaccurate 'Raises: HindsightError' clause from
   create_hindsight_tools — resolution always succeeds (URL defaults to
   Cloud) so it never raises; the error type stays exported as the
   conventional public catch type.
4. Replace the _TOOL_DEFS dict-of-3-tuples with a frozen _ToolDef dataclass
   and drop the redundant method-name field (it equalled the dict key).
2026-06-08 14:27:52 -04:00
DK09876 3ab39a8d3c Merge remote-tracking branch 'origin/main' into feat/haystack-integration
# Conflicts:
#	scripts/release-integration.sh
2026-06-08 08:29:07 -07:00
DK09876 1a39843e58 Merge remote-tracking branch 'origin/main' into haystack-fix2
# Conflicts:
#	scripts/release-integration.sh
2026-06-06 21:52:26 -07:00
DK09876 0fb376f5e7 ci: trailing newline to force CI retrigger 2026-06-06 21:50:47 -07:00
DK09876 ef37dd6347 ci: empty commit to attach pull_request CI check to the PR head
(Previous pushes did not auto-trigger pull_request workflow events for
reasons internal to GitHub Actions; manual workflow_dispatch runs passed
green but their checks don't roll up onto the PR. Re-poking the head
to surface the green state on the PR.)
2026-06-05 16:02:45 -07:00
DK09876 7ac2c6516c ci: re-trigger CI
A previous push to this branch silently did not trigger a pull_request
event in GitHub Actions, leaving the PR without a CI run for the latest
HEAD. Push an empty commit to force a new event.
2026-06-05 15:35:42 -07:00
DK09876andClaude Opus 4.7 febf3528a6 fix(haystack): strip api_key from to_dict() so it doesn't leak to YAML
_build_backend_kwargs was emitting the api_key in the serializable dict
that to_dict() returns. Haystack pipelines get dumped to YAML for
inspection, checkpointing, and sharing — a serialized key leaks into
every dump. Reviewer (benfrank241) flagged this on #1256.

Drop the api_key from the serialized backend_kwargs. resolve_client()
already reads HINDSIGHT_API_KEY from the env var as a final fallback,
so a redeployed pipeline picks the key back up from the host's
environment rather than from the YAML.

The test_tools_round_trip_serialization_with_client test previously
asserted the leak — flipped it to assert the key is NOT present
and added a json.dumps probe asserting the literal key value also
doesn't appear under any other field name. Pre-fix, the test fails:
  AssertionError: api_key must not appear in serialized backend_kwargs
   — would leak to YAML pipeline dumps
  assert 'api_key' not in {'api_key': 'client-key', ...}

86/86 tests pass post-fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-05 14:21:41 -07:00
DK09876 30d8993272 Merge remote-tracking branch 'origin/main' into feat/haystack-integration 2026-06-05 12:49:30 -07:00
DK09876andClaude Opus 4.7 6766e23f92 chore(haystack): sync openapi.json with main
Branch carried an older snapshot of hindsight-docs/static/openapi.json
that pre-dated main's addition of OperationProgress. Re-checkout main's
openapi.json so check-openapi-compatibility passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-05 12:41:35 -07:00
DK09876andClaude Opus 4.7 a3e1e691ae fix(haystack): close owned clients at exit; run E2E client I/O on the bridge loop
The tools run async client calls on a persistent background event loop. aiohttp
sessions bound to that loop were never closed, surfacing as "Unclosed client
session/connector" warnings. Track module-owned Hindsight clients (those created
when the caller didn't pass client=) and close them on the loop via an atexit
hook, then stop the loop. The live E2E now performs all client I/O through that
same loop (acreate_bank/adelete_bank/aclose via _run_sync) and logs cleanup
failures instead of swallowing them — zero unclosed-connector warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-05 08:38:58 -07:00
DK09876andClaude Opus 4.7 ea3ecdaf02 fix(haystack): default to Cloud without configure(); add gated E2E + bucketing
- resolve_client() now falls back to DEFAULT_HINDSIGHT_API_URL and the
  HINDSIGHT_API_KEY env var when configure() was never called (it previously
  raised "No Hindsight API URL configured"). Updated the unit test to assert
  the cloud-default + env-key behavior. Satisfies the "default to Cloud" goal.
- Add a gated tests/test_e2e.py (retain/recall/reflect tools against a live
  Hindsight server), marked requires_real_llm; register the marker in
  pyproject; the test-haystack-integration CI job now runs the deterministic
  bucket (-m "not requires_real_llm").

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-05 08:38:58 -07:00
DK09876andClaude Opus 4.6 687db27cc9 fix(haystack): address review round 2 — max_recall_results, role metadata, _run_sync cleanup
- Add max_recall_results param to HindsightToolset (default 10) to cap
  auto-recall prompt injection size, matching Pydantic AI pattern
- Auto-retain now includes role + source metadata on messages, matching
  LlamaIndex's metadata pattern for distinguishable conversation turns
- _recall_for_prompt now calls the API directly with result cap instead
  of going through the formatted string from recall_memory
- Serialize/deserialize max_recall_results in to_dict/from_dict
- Add tests for max_recall_results and role metadata (82 total)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-06-05 08:38:58 -07:00
DK09876andClaude Opus 4.6 1fb1bf080d feat(haystack): add HindsightToolset with auto-recall/retain, fix review issues
- Add HindsightToolset(Toolset) with auto_recall and auto_retain flags
  that automatically inject recalled memories into the system prompt
  before each turn and retain user/assistant messages after each turn
- Fix _ensure_bank to retry on transient errors instead of permanently
  disabling bank creation
- Fix reflect_on_memory to return structured_output JSON when
  response_schema is set
- Truncate error messages to avoid dumping raw HTTP responses to agents
- Extract _build_backend_kwargs() and _build_tools() as shared helpers
- Add 20 new tests (60 -> 80 total) covering toolset, auto-recall,
  auto-retain, structured output, and bank creation retry

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-06-05 08:38:58 -07:00
DK09876andClaude Opus 4.6 f9c06113a3 fix(haystack): use persistent event loop for async client calls
aiohttp binds its session to the creating event loop, so asyncio.run()
(which creates/destroys a loop per call) breaks on sequential calls.
Switch to a persistent daemon-thread event loop with
run_coroutine_threadsafe. Also removes unused per-operation timeout
constants and adds _run_sync tests.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-06-05 08:38:58 -07:00
DK09876andClaude Opus 4.6 2a7c496e28 feat(integrations): add Haystack integration for persistent agent memory
Add hindsight-haystack package providing Haystack Tool instances backed
by Hindsight's retain/recall/reflect APIs. Uses async client methods with
event-loop-safe sync wrapper to work correctly inside Haystack's agent
runtime.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-06-05 08:38:58 -07:00
1477 changed files with 14607 additions and 135830 deletions
-1
View File
@@ -1,7 +1,6 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "hindsight",
"version": "0.7.2",
"description": "Official Hindsight integrations for Claude Code",
"owner": {
"name": "vectorize-io"
-12
View File
@@ -192,18 +192,6 @@ If a migration adds a new PostgreSQL table (look for `CREATE TABLE` / `op.create
- The guard test `test_backup_tables_covers_entire_schema` in `tests/test_admin_backup_restore.py` enforces this — flag it as a **must fix** if a new table is absent from `BACKUP_TABLES`.
- Oracle-only tables (e.g. `observation_sources`) are intentionally excluded — admin backup/restore is PostgreSQL-only.
### 11b. Check new config flags update the env template
If the diff adds a new configuration field (a new `ENV_*` / `HINDSIGHT_*` env var
in `hindsight-api-slim/hindsight_api/config.py`):
- **`.env.example`** (repo root) — must add the variable (commented if optional)
alongside the docs entry in `hindsight-docs/docs/developer/configuration.md`.
A flag added to `config.py` but absent from `.env.example` is a **should fix**.
- **`hindsight-embed/hindsight_embed/env.example`** — the bundled copy must stay
byte-identical to the repo-root `.env.example` (it seeds embed/profile configs).
The `test_bundled_template_matches_repo_root` sync test fails on drift; if the
root file changed without re-copying, flag it as a **must fix**.
### 12. Review against other coding standards
Check the diff for violations of the standards listed above:
-116
View File
@@ -1,116 +0,0 @@
---
name: hs-release
description: Cut a core Hindsight release (vX.Y.Z) and open the changelog + blog PR. Use when asked to cut/start a release, bump the version, or publish a new Hindsight version.
user_invocable: true
---
# Hindsight Release
Cut a **core** Hindsight release and open the accompanying changelog/blog PR. This is for the core
product version (API, clients, CLI, control plane, Helm). **Integrations are versioned
independently** — use `scripts/release-integration.sh` for those, not this skill.
The release is **irreversible and outward-facing**: it tags a version and pushes it straight to
`main`, which triggers CI that publishes packages to PyPI / npm / Helm. Confirm the version number
and that the intended fixes are already merged to `main` before you start.
## Step 0 — Pre-flight
1. **Decide the base.** A release is cut from the latest `origin/main`, never from a feature
branch. `git fetch origin --tags` first. Confirm the "couple of fixes" the user means are
actually merged to `main` (`git log v<prev>..origin/main --oneline`).
2. **Find where `main` is checked out.** `main` is often already checked out in a sibling worktree
(`git worktree list`). You **cannot** check out `main` in a second worktree — run the release in
the worktree that already holds it. If that worktree is dirty with throwaway cruft
(`.next-*` tsconfig paths, screenshots), `git stash push -u`, fast-forward to `origin/main`,
run the release, then `git stash pop`.
3. **Pitfall:** never pipe the checkout in an `&&` chain like
`git checkout main 2>&1 | tail && git reset --hard ...` — the pipe's exit status is `tail`'s
(always 0), so a failed checkout won't stop the chain and the `reset` fires on the **wrong
branch**. Check out as its own command and verify `git branch --show-current` before resetting.
## Step 1 — Cut the release
Run from the worktree on a clean `main`:
```bash
./scripts/release.sh <version> # e.g. 0.8.1 (no leading v)
```
`release.sh` bumps the version in every component, regenerates the OpenAPI spec + all client SDKs,
updates docs versioning, commits `Release v<version>`, tags `v<version>`, and **pushes the commit
and tag directly to `main`**. The push triggers the `Release` GitHub Actions workflow that builds
and publishes the packages. It is **not** a PR.
Verify after: `gh run list --limit 5` should show the `Release v<version>` workflow running, and
`git ls-remote --tags origin v<version>` should return the tag.
## Step 2 — Changelog + blog PR (separate)
Done **after** the tag exists, as its own PR (precedent: v0.8.0 = #2053, v0.8.1 = #2080). Work on a
branch off the new `main`:
```bash
git checkout -b docs-changelog-<version> origin/main
```
Only spin up a separate worktree (`git worktree add ../hindsight-changelog-<version> -b
docs-changelog-<version> origin/main`) if you can't get a clean checkout otherwise — e.g. `main` is
held in another worktree and the current one has work you don't want to disturb.
**Branch naming:** use the `docs-` (hyphen) convention, e.g. `docs-changelog-0.8.1`. A remote
branch literally named `docs` exists, so any `docs/...` branch is rejected on push with
`directory file conflict`.
### Changelog
```bash
uv run --directory hindsight-dev generate-changelog <version>
```
LLM-summarizes the commits between the previous tag and `v<version>` and prepends an entry to
`hindsight-docs/src/pages/changelog/index.md`. Requires `OPENAI_API_KEY` (already in the repo
`.env`). It excludes `hindsight-integrations/` source, but new integrations whose commits also
touched docs will still appear — that matches precedent, leave them in the **changelog**.
### Blog post
Hand-write `hindsight-docs/blog/YYYY-MM-DD-version-X-Y-Z.md` (mirror an existing one; patch
releases are short — see `2026-06-02-version-0-7-2.md`). Guidance:
- **Explain user impact, not internals/mechanism.** Lead with what the user can now do and what to
set. Config/env-var names are fine (developer-facing), code symbols and internals are not.
- **Do not list integrations in the release blog.** The core blog covers core engine / API /
ops changes; each integration ships its own changelog. (Integrations may still appear in the
generated `changelog/index.md` — that's fine; just keep them out of the blog.)
- Call out an upgrade recommendation when there are operational/data-integrity fixes.
- Validate formatting: `npx prettier --check <blog file>`.
### Sync the docs skill
```bash
./scripts/generate-docs-skill.sh
```
Refreshes `skills/hindsight-docs/references/changelog/index.md`. It will also bump
`skills/hindsight-docs/references/openapi.json` by one version — `release.sh` regenerates the skill
*before* bumping OpenAPI, so the skill copy lags a version in the release commit; this step syncs
it. Expect a one-line `version` diff there; keep it.
### Commit, push, PR
```bash
git add -A
git commit --no-verify -m "docs: changelog and blog post for v<version>"
git push -u origin docs-changelog-<version>
gh pr create --base main --title "docs: changelog and blog post for v<version>" --body "..."
```
Expected files in the PR: the changelog entry, the new blog post, the regenerated skill changelog
mirror, and the skill `openapi.json` version sync.
## Cleanup
If you created a temporary worktree, remove it once the PR is up
(`git worktree remove ../hindsight-changelog-<version>`; the branch stays on origin). Restore any
stash you popped in Step 0.
+1 -54
View File
@@ -2,7 +2,7 @@
# Copy this file to .env and fill in your values
# LLM Configuration (Required)
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai, minimax, deepseek, zai, atlas, volcano
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai, minimax, deepseek, zai, volcano
HINDSIGHT_API_LLM_PROVIDER=openai
HINDSIGHT_API_LLM_API_KEY=your-api-key-here
HINDSIGHT_API_LLM_MODEL=gpt-4o-mini
@@ -10,17 +10,6 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# Reasoning effort for providers/models that support it. Examples: low, medium, high, xhigh.
# HINDSIGHT_API_LLM_REASONING_EFFORT=low
# Sampling temperature for internal LLM calls. Set a number in [0.0, 2.0], or `none`
# to omit the temperature parameter entirely (required for models that reject explicit
# temperatures, e.g. Azure gpt-5.5). The global override below applies to every operation;
# per-operation overrides (defaults: verification=0.0, retain=0.1, reflect=0.9,
# consolidation=0.0) take precedence.
# HINDSIGHT_API_LLM_TEMPERATURE=none
# HINDSIGHT_API_LLM_TEMPERATURE_VERIFICATION=0.0
# HINDSIGHT_API_LLM_TEMPERATURE_RETAIN=0.1
# HINDSIGHT_API_LLM_TEMPERATURE_REFLECT=0.9
# HINDSIGHT_API_LLM_TEMPERATURE_CONSOLIDATION=0.0
# Example: Anthropic Claude configuration
# HINDSIGHT_API_LLM_PROVIDER=anthropic
# HINDSIGHT_API_LLM_API_KEY=your-anthropic-api-key
@@ -48,41 +37,16 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# HINDSIGHT_API_LLM_API_KEY=your-zai-api-key
# HINDSIGHT_API_LLM_MODEL=glm-4.5-flash # or glm-4.5-air for the paid tier
# Example: Atlas Cloud configuration (OpenAI-compatible, https://www.atlascloud.ai)
# HINDSIGHT_API_LLM_PROVIDER=atlas
# HINDSIGHT_API_LLM_API_KEY=your-atlascloud-api-key
# HINDSIGHT_API_LLM_MODEL=deepseek-ai/deepseek-v4-pro # reasoning model; also Qwen / GLM / Kimi / MiniMax, etc.
# Example: LM Studio local configuration (Qwen 2.5 32B recommended)
# HINDSIGHT_API_LLM_PROVIDER=lmstudio
# HINDSIGHT_API_LLM_API_KEY=lmstudio
# HINDSIGHT_API_LLM_BASE_URL=http://localhost:1234/v1
# HINDSIGHT_API_LLM_MODEL=qwen2.5-32b-instruct
# Multi-LLM strategies: configure extra LLMs by index alongside the primary above,
# then pick a routing strategy. Unset = single primary LLM (default). Members are
# numbered from 1; indices must be contiguous. Each operation can override with a
# RETAIN_/REFLECT_/CONSOLIDATION_ prefix (e.g. HINDSIGHT_API_RETAIN_LLM_1_PROVIDER).
# HINDSIGHT_API_LLM_1_PROVIDER=groq
# HINDSIGHT_API_LLM_1_API_KEY=your-groq-api-key
# HINDSIGHT_API_LLM_1_MODEL=openai/gpt-oss-120b
# HINDSIGHT_API_LLM_2_PROVIDER=anthropic
# HINDSIGHT_API_LLM_2_API_KEY=your-anthropic-api-key
# Strategy JSON: {"mode": "failover"} or {"mode": "round-robin"}.
# Round-robin accepts optional positive-int "weights" (one per member, primary first).
# HINDSIGHT_API_LLM_STRATEGY={"mode": "failover"}
# API Configuration (Optional)
HINDSIGHT_API_HOST=0.0.0.0
HINDSIGHT_API_PORT=8888
HINDSIGHT_API_LOG_LEVEL=info
# Optional retain chunking override for structured logs/transcripts.
# Unset uses HINDSIGHT_API_RETAIN_CHUNK_SIZE as the structured-chunk limit.
# HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE=
# Dry-run extraction preview endpoint (POST /memories/dry-run-extract). Enabled by default; it makes
# a real LLM call but stores nothing. Set to false to remove the endpoint (returns 404).
# HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=true
# Base Path / Reverse Proxy Support (Optional)
# Set these when deploying behind a reverse proxy with path-based routing
@@ -95,7 +59,6 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_READ_DATABASE_URL= # Optional read-replica URL. When set, recall queries (semantic, BM25, graph, temporal) flow through a separate pool against this URL, offloading the primary. Typically points to a read-only endpoint (CNPG's <cluster>-ro service or Aurora reader endpoint).
# HINDSIGHT_API_MIGRATION_DATABASE_URL= # Direct PostgreSQL URL for migrations (bypasses PgBouncer). Falls back to DATABASE_URL.
# HINDSIGHT_API_DATABASE_SCHEMA=public # PostgreSQL schema name (default: public)
# HINDSIGHT_API_MIGRATION_CONCURRENCY=1 # Tenant schemas to migrate concurrently (PG only, each in its own process; per-schema work stays sequential). Each worker has ~1-2s startup cost + uses ~3 DB connections, so it only pays off with many schemas (tens+) or slow migrations; keep concurrency*3 <= spare max_connections. Default: 1 (sequential).
# Vector Extension (Optional - uses pgvector by default)
# Options: "pgvector" (default), "vchord", "pgvectorscale" (DiskANN)
@@ -116,18 +79,6 @@ HINDSIGHT_API_LOG_LEVEL=info
# korean_lindera/lindera(korean), ngram(min,max), edge_ngram(min,max)
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER=
# File Parser (Optional - uses markitdown by default)
# HINDSIGHT_API_FILE_PARSER=markitdown
# Enable image OCR for MarkItDown using an OpenAI-compatible OCR/vision endpoint.
# These OCR settings are independent from HINDSIGHT_API_LLM_* because MarkItDown
# uses the OpenAI SDK directly and requires Chat Completions image input support.
# When OCR is enabled, API_KEY, BASE_URL, and MODEL are required.
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED=false
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY=
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL=
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL=
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_PROMPT=
# Embeddings Configuration (Optional - uses local by default)
# Provider: "local" (default), "onnx", "tei", "openai", "cohere", "google", "openrouter", "zeroentropy", "litellm", or "litellm-sdk"
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
@@ -191,10 +142,6 @@ HINDSIGHT_API_LOG_LEVEL=info
# Custom service name and environment (optional, defaults: hindsight-api, development)
# HINDSIGHT_API_OTEL_SERVICE_NAME=hindsight-production
# HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT=production
#
# Expose async-operation queue + consolidation-backlog gauges on /metrics.
# Runs periodic per-schema COUNT queries on a background task (disabled by default).
# HINDSIGHT_API_METRICS_BACKLOG_ENABLED=true
# -----------------------------------------------------------------------------
# Control Plane (Optional)
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+3 -77
View File
@@ -9,11 +9,7 @@ jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write # for PyPI trusted publishing + build-provenance attestations
attestations: write # for actions/attest-build-provenance (Obsidian assets)
# No `contents: write`: we never create releases in this repo. The Obsidian
# plugin's distribution release is pushed to its dedicated repo using
# OBSIDIAN_DIST_TOKEN (see the "Mirror Obsidian plugin" step below).
id-token: write # for PyPI trusted publishing
steps:
- uses: actions/checkout@v6
@@ -75,7 +71,7 @@ jobs:
if: steps.type.outputs.type == 'plugin'
run: |
echo "Plugin integration ${{ steps.info.outputs.integration }} v${{ steps.info.outputs.version }} — no package to publish."
echo "Users install via: claude plugin marketplace add vectorize-io/hindsight"
echo "Users install via: claude plugin marketplace add vectorize-io/hindsight --sparse hindsight-integrations"
# ── TypeScript integrations (ai-sdk, chat, openclaw) ────────────────────
@@ -116,71 +112,6 @@ jobs:
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: npm run build
# Build-provenance attestations for the Obsidian release assets (community-store
# recommendation). Runs after the build so main.js exists. The assets are
# released in the dedicated repo while the build runs here, so users verify at
# owner scope: `gh attestation verify main.js --owner vectorize-io`.
- name: Attest Obsidian plugin build provenance
if: steps.type.outputs.type == 'typescript' && steps.info.outputs.integration == 'obsidian'
uses: actions/attest-build-provenance@v2
with:
subject-path: |
hindsight-integrations/obsidian/main.js
hindsight-integrations/obsidian/styles.css
# ── Obsidian plugin — mirror to its dedicated repo + cut the BRAT release ──
# We do NOT create a GitHub Release in this monorepo: per-integration
# releases pollute the repo's release list (it's for the core product) and
# steal the "Latest" badge, and BRAT / the community store read a repo's
# *latest* release — not a tag — so they can't target a tag in a monorepo.
#
# Instead this monorepo stays the source of truth, and on each obsidian
# release we mirror hindsight-integrations/obsidian/ → the *root* of
# github.com/vectorize-io/hindsight-obsidian (git subtree, history
# preserved) and cut the BRAT / community-store release *there*.
#
# Requires secret OBSIDIAN_DIST_TOKEN — a token with `contents: write` on
# vectorize-io/hindsight-obsidian (fine-grained PAT or app installation
# token). The dedicated repo is generated; never edit it directly.
- name: Mirror Obsidian plugin to its dedicated repo
if: steps.type.outputs.type == 'typescript' && steps.info.outputs.integration == 'obsidian'
env:
DIST_TOKEN: ${{ secrets.OBSIDIAN_DIST_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ steps.info.outputs.version }}"
DIST_REPO="vectorize-io/hindsight-obsidian"
OBS_DIR="hindsight-integrations/obsidian"
# `git subtree split` needs full history; the default checkout is shallow.
git fetch --unshallow 2>/dev/null || true
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# The runner injects the default GITHUB_TOKEN as an http.extraheader via
# an *included* config file (/home/runner/work/_temp/git-credentials-*.config),
# so `git config --local --unset-all` can't remove it and it authenticates
# the push as github-actions[bot] (no access to the dedicated repo → 403).
# The documented way to drop an inherited extraheader is to RESET the list
# with an empty value: since command-line `-c` is read last, the empty
# value clears the accumulated headers (including the included one) at
# request-build time. The dist token then comes from the push URL → a
# single Authorization header.
git subtree split --prefix="$OBS_DIR" -b _obs_dist
git -c "http.https://github.com/.extraheader=" \
push "https://x-access-token:${DIST_TOKEN}@github.com/${DIST_REPO}.git" _obs_dist:main
# Cut the BRAT / community-store release. Bare version tag (e.g. 0.1.0)
# to match manifest.json — idempotent so re-runs just refresh the assets.
export GH_TOKEN="$DIST_TOKEN"
ASSETS="$OBS_DIR/main.js $OBS_DIR/manifest.json $OBS_DIR/styles.css"
NOTES="Hindsight for Obsidian v${VERSION}. Install via BRAT (add ${DIST_REPO}) or copy main.js/manifest.json/styles.css into <vault>/.obsidian/plugins/hindsight/."
if gh release view "$VERSION" --repo "$DIST_REPO" >/dev/null 2>&1; then
gh release upload "$VERSION" $ASSETS --repo "$DIST_REPO" --clobber
else
gh release create "$VERSION" $ASSETS --repo "$DIST_REPO" --title "$VERSION" --notes "$NOTES"
fi
- name: Publish TypeScript package to npm
if: steps.type.outputs.type == 'typescript'
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
@@ -190,12 +121,7 @@ jobs:
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
# Treat "already published" as success so re-pointed-tag re-runs stay green.
# "cannot publish over" = the version exists. TLOG_CREATE_ENTRY_ERROR / 409
# "equivalent entry already exists in the transparency log" = the identical
# --provenance artifact was already logged on a prior run (Sigstore tlog is
# idempotent); the package is published, so this is benign.
if echo "$OUTPUT" | grep -qE "cannot publish over|TLOG_CREATE_ENTRY_ERROR|already exists in the transparency log"; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
+2 -2
View File
@@ -266,7 +266,7 @@ jobs:
strategy:
matrix:
include:
- os: ubuntu-22.04
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact_name: hindsight
asset_name: hindsight-linux-amd64
@@ -278,7 +278,7 @@ jobs:
target: aarch64-apple-darwin
artifact_name: hindsight
asset_name: hindsight-darwin-arm64
- os: ubuntu-22.04-arm
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
artifact_name: hindsight
asset_name: hindsight-linux-arm64
+5 -666
View File
@@ -32,36 +32,24 @@ jobs:
integration-tests: ${{ steps.filter.outputs.integration-tests }}
integrations-openclaw: ${{ steps.filter.outputs.integrations-openclaw }}
integrations-ai-sdk: ${{ steps.filter.outputs.integrations-ai-sdk }}
integrations-agent-framework: ${{ steps.filter.outputs.integrations-agent-framework }}
integrations-composio: ${{ steps.filter.outputs.integrations-composio }}
integrations-chat: ${{ steps.filter.outputs.integrations-chat }}
integrations-claude-code: ${{ steps.filter.outputs.integrations-claude-code }}
integrations-cline: ${{ steps.filter.outputs.integrations-cline }}
integrations-codex: ${{ steps.filter.outputs.integrations-codex }}
integrations-github-copilot: ${{ steps.filter.outputs.integrations-github-copilot }}
integrations-continue: ${{ steps.filter.outputs.integrations-continue }}
integrations-cursor-cli: ${{ steps.filter.outputs.integrations-cursor-cli }}
integrations-crewai: ${{ steps.filter.outputs.integrations-crewai }}
integrations-litellm: ${{ steps.filter.outputs.integrations-litellm }}
integrations-pydantic-ai: ${{ steps.filter.outputs.integrations-pydantic-ai }}
integrations-ag2: ${{ steps.filter.outputs.integrations-ag2 }}
integrations-autogen: ${{ steps.filter.outputs.integrations-autogen }}
integrations-aider: ${{ steps.filter.outputs.integrations-aider }}
integrations-langgraph: ${{ steps.filter.outputs.integrations-langgraph }}
integrations-llamaindex: ${{ steps.filter.outputs.integrations-llamaindex }}
integrations-paperclip: ${{ steps.filter.outputs.integrations-paperclip }}
integrations-opencode: ${{ steps.filter.outputs.integrations-opencode }}
integrations-eve: ${{ steps.filter.outputs.integrations-eve }}
integrations-cursor: ${{ steps.filter.outputs.integrations-cursor }}
integrations-zed: ${{ steps.filter.outputs.integrations-zed }}
integrations-n8n: ${{ steps.filter.outputs.integrations-n8n }}
integrations-zapier: ${{ steps.filter.outputs.integrations-zapier }}
integrations-cloudflare-oauth-proxy: ${{ steps.filter.outputs.integrations-cloudflare-oauth-proxy }}
integrations-superagent: ${{ steps.filter.outputs.integrations-superagent }}
integrations-lockfiles: ${{ steps.filter.outputs.integrations-lockfiles }}
integrations-openai-agents: ${{ steps.filter.outputs.integrations-openai-agents }}
integrations-openhands: ${{ steps.filter.outputs.integrations-openhands }}
integrations-devin-desktop: ${{ steps.filter.outputs.integrations-devin-desktop }}
integrations-pipecat: ${{ steps.filter.outputs.integrations-pipecat }}
integrations-agentcore: ${{ steps.filter.outputs.integrations-agentcore }}
integrations-smolagents: ${{ steps.filter.outputs.integrations-smolagents }}
@@ -71,8 +59,6 @@ jobs:
integrations-vapi: ${{ steps.filter.outputs.integrations-vapi }}
integrations-flowise: ${{ steps.filter.outputs.integrations-flowise }}
integrations-google-adk: ${{ steps.filter.outputs.integrations-google-adk }}
integrations-obsidian: ${{ steps.filter.outputs.integrations-obsidian }}
integrations-omo: ${{ steps.filter.outputs.integrations-omo }}
integrations-haystack: ${{ steps.filter.outputs.integrations-haystack }}
tools-agent-sdk: ${{ steps.filter.outputs.tools-agent-sdk }}
integrations-roo-code: ${{ steps.filter.outputs.integrations-roo-code }}
@@ -134,22 +120,12 @@ jobs:
- 'hindsight-integrations/openclaw/**'
integrations-ai-sdk:
- 'hindsight-integrations/ai-sdk/**'
integrations-agent-framework:
- 'hindsight-integrations/agent-framework/**'
integrations-composio:
- 'hindsight-integrations/composio/**'
integrations-chat:
- 'hindsight-integrations/chat/**'
integrations-claude-code:
- 'hindsight-integrations/claude-code/**'
integrations-cline:
- 'hindsight-integrations/cline/**'
integrations-codex:
- 'hindsight-integrations/codex/**'
integrations-github-copilot:
- 'hindsight-integrations/github-copilot/**'
integrations-continue:
- 'hindsight-integrations/continue/**'
integrations-cursor-cli:
- 'hindsight-integrations/cursor-cli/**'
integrations-crewai:
@@ -162,8 +138,6 @@ jobs:
- 'hindsight-integrations/ag2/**'
integrations-autogen:
- 'hindsight-integrations/autogen/**'
integrations-aider:
- 'hindsight-integrations/aider/**'
integrations-langgraph:
- 'hindsight-integrations/langgraph/**'
integrations-llamaindex:
@@ -174,16 +148,8 @@ jobs:
- 'hindsight-integrations/paperclip/**'
integrations-opencode:
- 'hindsight-integrations/opencode/**'
integrations-eve:
- 'hindsight-integrations/eve/**'
integrations-cursor:
- 'hindsight-integrations/cursor/**'
integrations-zed:
- 'hindsight-integrations/zed/**'
integrations-n8n:
- 'hindsight-integrations/n8n/**'
integrations-zapier:
- 'hindsight-integrations/zapier/**'
integrations-cloudflare-oauth-proxy:
- 'hindsight-integrations/cloudflare-oauth-proxy/**'
integrations-superagent:
@@ -194,10 +160,6 @@ jobs:
- 'scripts/check-integration-lockfiles.sh'
integrations-openai-agents:
- 'hindsight-integrations/openai-agents/**'
integrations-openhands:
- 'hindsight-integrations/openhands/**'
integrations-devin-desktop:
- 'hindsight-integrations/devin-desktop/**'
integrations-pipecat:
- 'hindsight-integrations/pipecat/**'
integrations-agentcore:
@@ -216,10 +178,6 @@ jobs:
- 'hindsight-integrations/flowise/**'
integrations-google-adk:
- 'hindsight-integrations/google-adk/**'
integrations-obsidian:
- 'hindsight-integrations/obsidian/**'
integrations-omo:
- 'hindsight-integrations/omo/**'
tools-agent-sdk:
- 'hindsight-tools/hindsight-agent-sdk/**'
integrations-roo-code:
@@ -480,165 +438,6 @@ jobs:
working-directory: ./hindsight-integrations/claude-code
run: python -m pytest tests/ -v
test-cursor-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-cursor == '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 Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Run tests
working-directory: ./hindsight-integrations/cursor
run: python -m pytest tests/ -v
test-zed-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-zed == '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 Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install package and pytest
working-directory: ./hindsight-integrations/zed
# Installs the package (incl. the zstandard runtime dep) so the threads.db
# reader tests can decompress Zed's zstd blobs.
run: pip install -e . pytest
- name: Run tests
working-directory: ./hindsight-integrations/zed
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: python -m pytest tests/ -v -m "not requires_real_llm"
test-omo-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-omo == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Run tests
working-directory: ./hindsight-integrations/omo
run: python -m pytest tests/ -v
test-cline-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-cline == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build cline integration
working-directory: ./hindsight-integrations/cline
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/cline
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/cline
run: uv run pytest tests -v
test-github-copilot-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-github-copilot == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build github-copilot integration
working-directory: ./hindsight-integrations/github-copilot
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/github-copilot
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/github-copilot
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-codex-integration:
needs: [detect-changes]
if: >-
@@ -679,28 +478,17 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
python-version: '3.11'
- name: Build cursor-cli integration
working-directory: ./hindsight-integrations/cursor-cli
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/cursor-cli
run: uv sync --frozen
- name: Install pytest
run: pip install pytest
- name: Run tests
working-directory: ./hindsight-integrations/cursor-cli
run: uv run pytest tests -v
run: python -m pytest tests/ -v
build-ai-sdk-integration:
needs: [detect-changes]
@@ -796,37 +584,6 @@ jobs:
working-directory: ./hindsight-integrations/opencode
run: npm run build
test-eve-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-eve == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
- name: Install dependencies
working-directory: ./hindsight-integrations/eve
run: npm ci
- name: Run tests
working-directory: ./hindsight-integrations/eve
run: npm test
- name: Build
working-directory: ./hindsight-integrations/eve
run: npm run build
test-n8n-integration:
needs: [detect-changes]
if: >-
@@ -858,37 +615,6 @@ jobs:
working-directory: ./hindsight-integrations/n8n
run: npm run build
test-zapier-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-zapier == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install dependencies
working-directory: ./hindsight-integrations/zapier
run: npm install --no-fund --no-audit
- name: Validate app definition
working-directory: ./hindsight-integrations/zapier
run: npm run validate
- name: Run tests
working-directory: ./hindsight-integrations/zapier
run: npm test
test-hindsight-agent-sdk:
needs: [detect-changes]
if: >-
@@ -3140,45 +2866,6 @@ jobs:
working-directory: ./hindsight-integrations/ag2
run: uv run pytest tests -v
test-aider-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-aider == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build aider integration
working-directory: ./hindsight-integrations/aider
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/aider
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/aider
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-autogen-integration:
needs: [detect-changes]
if: >-
@@ -3218,88 +2905,6 @@ jobs:
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-composio-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-composio == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build composio integration
working-directory: ./hindsight-integrations/composio
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/composio
run: uv sync --frozen
- name: Lint
working-directory: ./hindsight-integrations/composio
run: uv run ruff check .
- name: Run tests
working-directory: ./hindsight-integrations/composio
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-continue-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-continue == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build continue integration
working-directory: ./hindsight-integrations/continue
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/continue
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/continue
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-smolagents-integration:
needs: [detect-changes]
if: >-
@@ -3403,84 +3008,6 @@ jobs:
working-directory: ./hindsight-integrations/flowise
run: npm test
test-obsidian-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-obsidian == '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/obsidian
run: npm install --no-audit --no-fund
- name: Type check
working-directory: ./hindsight-integrations/obsidian
run: npx tsc --noEmit
- name: Build
working-directory: ./hindsight-integrations/obsidian
run: npm run build
- name: Run tests
working-directory: ./hindsight-integrations/obsidian
run: npm test
test-agent-framework-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-agent-framework == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build agent-framework integration
working-directory: ./hindsight-integrations/agent-framework
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/agent-framework
run: uv sync --frozen
- name: Lint
working-directory: ./hindsight-integrations/agent-framework
run: uv run ruff check .
- name: Run tests
working-directory: ./hindsight-integrations/agent-framework
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-crewai-integration:
needs: [detect-changes]
if: >-
@@ -3827,84 +3354,6 @@ jobs:
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-openhands-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-openhands == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build openhands integration
working-directory: ./hindsight-integrations/openhands
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/openhands
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/openhands
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-devin-desktop-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-devin-desktop == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Build devin-desktop integration
working-directory: ./hindsight-integrations/devin-desktop
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/devin-desktop
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/devin-desktop
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
# (requires_real_llm) needs a live Hindsight server and runs separately.
run: uv run pytest tests -v -m "not requires_real_llm"
test-claude-agent-sdk-integration:
needs: [detect-changes]
if: >-
@@ -4049,49 +3498,6 @@ jobs:
echo "=== API Server Logs ==="
cat /tmp/slim-api-server.log 2>/dev/null || true
verify-embed-control-center-bundle:
# The control center UI (Preact + Tailwind) is built with Vite and its static
# output is committed (served as-is by the embed's Python http.server, no Node
# at runtime). We can't byte-diff the committed bundle against a fresh build —
# Vite's content-hashed asset filenames aren't reproducible across the CI
# runner's OS/arch vs the committer's. So instead verify: (1) the committed
# bundle is a real, wired Vite build (index.html references JS/CSS that exist),
# and (2) the source still builds cleanly.
needs: [detect-changes]
if: >-
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.embed == 'true' ||
needs.detect-changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: '22'
# Check the committed bundle BEFORE building (the build overwrites static/).
- name: Verify the committed bundle is wired
working-directory: ./hindsight-embed/hindsight_embed/control_center
run: |
test -f static/index.html || { echo "::error::static/index.html missing — run 'npm run build' in control_center/ui and commit static/"; exit 1; }
js=$(grep -oE 'assets/[A-Za-z0-9_.-]+\.js' static/index.html | head -1)
css=$(grep -oE 'assets/[A-Za-z0-9_.-]+\.css' static/index.html | head -1)
{ [ -n "$js" ] && [ -f "static/$js" ]; } || { echo "::error::index.html does not reference a committed JS bundle — rebuild the UI and commit static/"; exit 1; }
{ [ -n "$css" ] && [ -f "static/$css" ]; } || { echo "::error::index.html does not reference a committed CSS bundle — rebuild the UI and commit static/"; exit 1; }
echo "committed bundle is wired ✓"
- name: Verify the source builds cleanly
working-directory: ./hindsight-embed/hindsight_embed/control_center/ui
run: |
npm ci
npm run build
echo "control center UI builds ✓"
test-embed:
needs: [detect-changes]
if: >-
@@ -4307,8 +3713,7 @@ jobs:
target="$RUNNER_TEMP/install-test"
PYTHONPATH="$target" python -c "
from hindsight_embed.daemon_embed_manager import DaemonEmbedManager
# api_version is only used for the uvx fallback; the binary branch ignores it.
cmd = DaemonEmbedManager()._find_api_command('0.0.0')
cmd = DaemonEmbedManager()._find_api_command()
print('Resolved command:', cmd)
assert len(cmd) == 1 and cmd[0].endswith('hindsight-api.exe'), (
f'Expected sibling hindsight-api.exe, got {cmd!r}. '
@@ -4669,60 +4074,6 @@ jobs:
fi
done
# Dead-code detection beyond what ruff's F401/F841 catch (those are already
# BLOCKING via the ruff config + the verify-generated-files job).
#
# - knip (control plane): BLOCKING on unused files / dependencies / unlisted
# dependencies. These are unambiguous — an orphaned file or a dead
# package.json entry — so they fail the build.
# - vulture (Python) + knip unused *exports*: ADVISORY only. vulture's
# function/argument heuristics false-positive on FastAPI/SQLAlchemy/Pydantic
# patterns, and the control plane intentionally keeps an unused shadcn/ui
# component surface, so these are surfaced in the step summary, not gated.
check-unused-code:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.control-plane == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install Control Plane dependencies
run: npm install --workspace=hindsight-control-plane
- name: knip — unused files / dependencies (blocking)
working-directory: hindsight-control-plane
run: npx --yes knip@5 --no-progress --include files,dependencies,unlisted
- name: Advisory scan — vulture + knip exports
continue-on-error: true
run: |
{
echo '## Dead-code scan (advisory)'
echo ''
echo '```'
./scripts/hooks/check-unused.sh 2>&1 | sed 's/\x1b\[[0-9;]*m//g'
echo '```'
} | tee -a "$GITHUB_STEP_SUMMARY"
verify-generated-files:
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -4904,16 +4255,11 @@ jobs:
- build-openclaw-integration
- smoke-openclaw-install
- test-claude-code-integration
- test-cursor-integration
- test-cline-integration
- test-github-copilot-integration
- test-codex-integration
- test-cursor-cli-integration
- build-ai-sdk-integration
- test-ai-sdk-integration-deno
- test-opencode-integration
- test-eve-integration
- test-omo-integration
- test-cloudflare-oauth-proxy-integration
- build-chat-integration
- test-paperclip-integration
@@ -4941,14 +4287,10 @@ jobs:
- test-openclaw-integration
- test-integration
- test-ag2-integration
- test-aider-integration
- test-autogen-integration
- test-continue-integration
- test-smolagents-integration
- test-dify-integration
- test-flowise-integration
- test-obsidian-integration
- test-agent-framework-integration
- test-crewai-integration
- test-langgraph-integration
- test-superagent-integration
@@ -4956,14 +4298,11 @@ jobs:
- test-pydantic-ai-integration
- test-llamaindex-integration
- test-openai-agents-integration
- test-openhands-integration
- test-devin-desktop-integration
- test-agentcore-integration
- test-haystack-integration
- test-pip-slim
- test-embed
- test-embed-windows
- verify-embed-control-center-bundle
- test-hindsight-all
- test-hindsight-agent-sdk
- test-claude-agent-sdk-integration
-3
View File
@@ -6,7 +6,6 @@ dist/
wheels/
*.egg-info
.mcp.json
.playwright-mcp/
.osgrep
# Virtual environments
.venv
@@ -16,8 +15,6 @@ node_modules/
# Environment variables and local config
.env
.env.bak*
.env.*.bak
docker-compose.yml
docker-compose.override.yml
+5 -30
View File
@@ -216,18 +216,6 @@ migration file dispatches through `run_for_dialect`, which calls either
./scripts/hooks/lint.sh
```
Dead-code detection runs in CI (the `check-unused-code` job) at two levels:
- **Blocking:** unused imports (ruff `F401`) and variables (`F841`) — `lint.sh` auto-removes
them and `verify-generated-files` fails on any leftover diff; and **knip** for orphaned
control-plane files / unused (or unlisted) `package.json` dependencies.
- **Advisory:** whole unused Python functions (vulture) and unused control-plane *exports*
(the shadcn/ui surface is kept on purpose) — surfaced, not gated.
Run both locally with:
```bash
./scripts/hooks/check-unused.sh
```
**After completing any implementation work, run `/code-review`** to verify your changes against project standards (missing tests, dead code, type safety, etc.). Fix any "must fix" issues before considering the task done.
**MANDATORY: Run `/code-review` before pushing code or creating a pull request.** Do not push or create a PR until all "must fix" issues are resolved.
@@ -327,10 +315,7 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
```
2. **main.py** (`hindsight-api-slim/hindsight_api/main.py`):
- No change is needed for ordinary environment-backed config fields. The CLI starts from `_get_raw_config()`,
so new `HindsightConfig` fields are carried through automatically.
- If the new field should be overridable by a CLI flag, add the argparse option in `_parse_cli_args()` and include
that field in the `dataclasses.replace(config, ...)` call near the "CLI override" comment.
- Add field to the manual `HindsightConfig()` constructor call (search for "CLI override")
3. **Use hierarchical config in MemoryEngine**:
```python
@@ -350,16 +335,6 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
- Add to appropriate section table with Variable, Description, Default
- Mark if it's hierarchical (can be overridden per-bank)
6. **Env template** (`.env.example`):
- Add the variable to the appropriate section, commented if optional, with a
short inline comment describing it (mirror the documentation entry).
- This file is the single source of truth for the env template:
`scripts/dev/setup.sh` copies it to `.env`, and `hindsight-embed` ships a
bundled copy (`hindsight-embed/hindsight_embed/env.example`) that seeds
embed/profile configs. After editing `.env.example`, re-copy it to the
embed package (`cp .env.example hindsight-embed/hindsight_embed/env.example`)
or the `test_bundled_template_matches_repo_root` sync test will fail.
#### Hierarchical vs Static Guidelines
**Hierarchical** (per-bank overridable):
@@ -376,7 +351,7 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten
```bash
cp .env.example .env
# Edit .env with the LLM provider/model and credentials for your setup
# Edit .env with LLM API key
# Python deps
uv sync --directory hindsight-api-slim/
@@ -385,10 +360,10 @@ uv sync --directory hindsight-api-slim/
npm install
```
Common LLM settings:
Required env vars:
- `HINDSIGHT_API_LLM_PROVIDER`: openai, anthropic, gemini, groq, minimax, ollama, lmstudio
- `HINDSIGHT_API_LLM_API_KEY`: API key for providers that require one
- `HINDSIGHT_API_LLM_MODEL`: Model name (defaults are provider-specific)
- `HINDSIGHT_API_LLM_API_KEY`: Your API key
- `HINDSIGHT_API_LLM_MODEL`: Model name (e.g., gpt-4o-mini, claude-sonnet-4-20250514)
Optional (uses local models by default):
- `HINDSIGHT_API_EMBEDDINGS_PROVIDER`: local (default) or tei
+4 -18
View File
@@ -7,6 +7,7 @@
[![CI](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml/badge.svg)](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml)
[![Slack Community](https://img.shields.io/badge/Slack-Join%20Community-4A154B?logo=slack)](https://join.slack.com/t/hindsight-space/shared_invite/zt-3nhbm4w29-LeSJ5Ixi6j8PdiYOCPlOgg)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![gitcgr](https://gitcgr.com/badge/vectorize-io/hindsight.svg)](https://gitcgr.com/vectorize-io/hindsight)
![PyPI - Downloads](https://img.shields.io/pypi/dm/hindsight-api?label=PyPI)
![NPM Downloads](https://img.shields.io/npm/dm/%40vectorize-io%2Fhindsight-client?logoColor=orange&label=NPM&color=blue&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40vectorize-io%2Fhindsight-client)
<br/>
@@ -70,7 +71,7 @@ docker run -it --pull always --name hindsight --restart unless-stopped -p 8888:8
>API: http://localhost:8888
>UI: http://localhost:9999
You can modify the LLM provider by setting `HINDSIGHT_API_LLM_PROVIDER`. Valid options are `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `lmstudio`, `minimax`, and `atlas` ([Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=hindsight)). The documentation provides more details on [supported models](https://hindsight.vectorize.io/developer/models).
You can modify the LLM provider by setting `HINDSIGHT_API_LLM_PROVIDER`. Valid options are `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `lmstudio`, and `minimax`. The documentation provides more details on [supported models](https://hindsight.vectorize.io/developer/models).
@@ -142,8 +143,6 @@ main();
pip install hindsight-all -U
```
On Intel (x86_64) Macs, install `hindsight-all-slim` instead — see [Supported Platforms](#supported-platforms).
```python
import os
from hindsight import HindsightServer, HindsightClient
@@ -250,7 +249,7 @@ Recall performs 4 retrieval strategies in parallel:
- Graph: Entity/temporal/causal links
- Temporal: Time range filtering
![Recall Operation](hindsight-docs/static/img/recall-operation.webp)
![Retain Operation](hindsight-docs/static/img/recall-operation.webp)
The individual results from the retrievals are merged, then ordered by relevance using reciprocal rank fusion and a cross-encoder reranking model.
@@ -276,7 +275,7 @@ client = Hindsight(base_url="http://localhost:8888")
client.reflect(bank_id="my-bank", query="What should I know about Alice?")
```
![Reflect Operation](hindsight-docs/static/img/reflect-operation.webp)
![Retain Operation](hindsight-docs/static/img/reflect-operation.webp)
---
@@ -301,19 +300,6 @@ client.reflect(bank_id="my-bank", query="What should I know about Alice?")
[![Star History Chart](https://api.star-history.com/svg?repos=vectorize-io/hindsight&type=date&legend=top-left)](https://www.star-history.com/#vectorize-io/hindsight&type=date&legend=top-left)
---
## Supported Platforms
| Platform | Docker | Bare Metal (pip) | Embedded DB (pg0) |
|----------|--------|------------------|--------------------|
| **Linux** (x86_64, ARM64) | ✅ | ✅ | ✅ |
| **macOS** (Apple Silicon / arm64) | ✅ | ✅ | ✅ |
| **macOS** (Intel / x86_64) | ✅ | ⚠️ | ✅ |
| **Windows** (x86_64) | ✅ | ✅ | ✅ |
⚠️ Intel Macs: use `hindsight-all-slim` — see the [installation guide](https://hindsight.vectorize.io/developer/installation#supported-platforms) for details.
---
## Contributing
See [CONTRIBUTING.md](./CONTRIBUTING.md).
Generated
+1
View File
@@ -77,6 +77,7 @@
"npm:@radix-ui/react-dropdown-menu@^2.1.16",
"npm:@radix-ui/react-label@^2.1.8",
"npm:@radix-ui/react-popover@^1.1.15",
"npm:@radix-ui/react-radio-group@^1.3.8",
"npm:@radix-ui/react-select@^2.2.6",
"npm:@radix-ui/react-slider@^1.3.6",
"npm:@radix-ui/react-slot@^1.2.4",
@@ -1,6 +1,6 @@
name: hindsight
# Docker Compose file for Hindsight with PostgreSQL and vectorchord
# docker compose -f docker/docker-compose/vchord/docker-compose.yaml down && sleep 2 && docker compose -f docker/docker-compose/vchord/docker-compose.yaml up -d
# docker compose -f docker/docker-compose/docker-compose.yaml down && sleep 2 && docker compose -f docker/docker-compose/docker-compose.yaml up -d
# Make sure to set the required environment variables before running:
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
# - Configure LLM provider variables as needed (see below in the hindsight service)
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.8.4
appVersion: "0.8.4"
version: 0.8.0
appVersion: "0.8.0"
keywords:
- ai
- memory
+3 -3
View File
@@ -66,13 +66,13 @@ helm install hindsight ./helm/hindsight -n hindsight --create-namespace -f value
| Parameter | Description | Default |
|-----------|-------------|---------|
| `version` | Default image tag for all components | Chart `appVersion` |
| `version` | Default image tag for all components | `0.1.0` |
| `api.enabled` | Enable the API component | `true` |
| `api.image.repository` | API image repository | `ghcr.io/vectorize-io/hindsight-api` |
| `api.image.repository` | API image repository | `hindsight/api` |
| `api.image.tag` | API image tag (defaults to `version`) | - |
| `api.service.port` | API service port | `8888` |
| `controlPlane.enabled` | Enable the control plane | `true` |
| `controlPlane.image.repository` | Control plane image repository | `ghcr.io/vectorize-io/hindsight-control-plane` |
| `controlPlane.image.repository` | Control plane image repository | `hindsight/control-plane` |
| `controlPlane.image.tag` | Control plane image tag (defaults to `version`) | - |
| `controlPlane.service.port` | Control plane service port | `3000` |
| `postgresql.enabled` | Deploy PostgreSQL as subchart | `true` |
+3
View File
@@ -13,6 +13,9 @@
# - Any other env vars you want to inject
# existingSecret: "my-hindsight-secret"
# Global settings
replicaCount: 1
# Image settings for api
api:
enabled: true
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-all",
"version": "0.8.4",
"version": "0.8.0",
"description": "Node.js programmatic lifecycle manager for Hindsight — embeds a local hindsight daemon in a Node application. Pair with @vectorize-io/hindsight-client for memory operations.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-all-slim"
version = "0.8.4"
version = "0.8.0"
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim==0.8.4",
"hindsight-api-slim==0.8.0",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
]
+3 -3
View File
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-all"
version = "0.8.4"
version = "0.8.0"
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim[all]==0.8.4",
"hindsight-api-slim[all]==0.8.0",
"hindsight-client>=0.0.7",
"hindsight-embed>=0.1.0",
]
@@ -21,7 +21,7 @@ hindsight-embed = { workspace = true }
[project.optional-dependencies]
local-llm = [
"hindsight-api-slim[local-llm]==0.8.4",
"hindsight-api-slim[local-llm]==0.8.0",
]
test = [
"pytest>=7.0.0",
+1 -1
View File
@@ -121,7 +121,7 @@ This runs a stdio-based MCP server that can be used directly with MCP-compatible
- **Entity Graph** — Automatic entity extraction and relationship tracking
- **Temporal Reasoning** — Native support for time-based queries
- **Disposition Traits** — Configurable skepticism, literalism, and empathy influence opinion formation
- **Three Memory Types** — World facts, experience facts (the bank's own actions), and observations
- **Three Memory Types** — World facts, bank actions, and formed opinions with confidence scores
## Documentation
+1 -1
View File
@@ -53,4 +53,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.8.4"
__version__ = "0.8.0"
@@ -54,20 +54,23 @@ _INDEX_TYPE_KEYWORDS = {
# pre-dispatcher code (internal benchmarks tuned around our embedding count
# and recall floor; see the link_utils / pool init call sites for the
# latency-vs-recall framing).
# - vchord exposes vchordrq.probes, but its shape must match the index's
# build.internal.lists hierarchy. VectorChord 1.1 added per-index fallback
# parameters for this reason: a session GUC overrides every vchordrq index,
# and a single value can be invalid for listless or mixed-layout indexes.
# Hindsight's built-in vchord clause does not set lists, so the safe default
# is no session-level probe override; deployments that partition vchordrq
# indexes should attach probes to the index storage parameters instead.
# - vchord exposes vchordrq.probes (no default; see VectorChord issue #392)
# and vchordrq.epsilon (default 1.9). probes = 10 / 30 are starting
# defaults pending a workload-specific sweep — vchordrq's recall curve
# shape differs from HNSW's, so the pgvector numbers don't translate
# directly. Revisit with a per-cluster benchmark once we have production
# recall data; until then these are deliberately conservative on the
# high-recall path. We leave epsilon at its default; tightening it is a
# separate trade-off.
# - pgvectorscale / pg_diskann / scann do not expose an equivalent per-statement
# knob in the engine today, so the dispatcher returns no statements for them.
_ANN_TUNING_LOW_LATENCY: dict[str, tuple[tuple[str, str], ...]] = {
"pgvector": (("hnsw.ef_search", "60"),),
"vchord": (("vchordrq.probes", "10"),),
}
_ANN_TUNING_HIGH_RECALL: dict[str, tuple[tuple[str, str], ...]] = {
"pgvector": (("hnsw.ef_search", "200"),),
"vchord": (("vchordrq.probes", "30"),),
}
_EXTENSION_INSTALL_SQL = {
+32 -34
View File
@@ -49,14 +49,12 @@ BACKUP_TABLES = [
"entities",
"chunks",
"memory_units",
"invalidated_memory_units",
"unit_entities",
"entity_cooccurrences",
"memory_links",
"observation_history",
"mental_models",
"mental_model_history",
"knowledge_pages",
"directives",
"async_operations",
"webhooks",
@@ -257,10 +255,14 @@ async def _run_migration(
schema: str | None = None,
base_schema: str = DEFAULT_DATABASE_SCHEMA,
embedding_dimension: int | None = None,
ensure_extensions: bool = True,
) -> list[str]:
"""Resolve database URL and run migrations for one schema or all discovered schemas."""
from ..migrations import run_migrations_for_schemas
from ..migrations import (
ensure_embedding_dimension,
ensure_text_search_extension,
ensure_vector_extension,
run_migrations,
)
is_pg0, instance_name, _ = parse_pg0_url(db_url)
if is_pg0:
@@ -281,21 +283,32 @@ async def _run_migration(
# Preserve order while removing duplicates.
schemas = list(dict.fromkeys(schemas))
# Migrate up to `migration_concurrency` schemas at once (each in its own
# process); within a schema the work stays sequential. Run off the event
# loop so the process pool's blocking joins don't stall it.
await asyncio.to_thread(
run_migrations_for_schemas,
resolved_url,
schemas,
concurrency=config.migration_concurrency,
migration_database_url=config.migration_database_url,
embedding_dimension=embedding_dimension,
vector_extension=config.vector_extension,
text_search_extension=config.text_search_extension,
pg_search_tokenizer=config.text_search_extension_pg_search_tokenizer,
ensure_extensions=ensure_extensions,
)
for schema in schemas:
run_migrations(resolved_url, schema=schema, migration_database_url=config.migration_database_url)
if embedding_dimension is not None:
for schema in schemas:
ensure_embedding_dimension(
resolved_url,
embedding_dimension,
schema=schema,
vector_extension=config.vector_extension,
)
for schema in schemas:
ensure_vector_extension(
resolved_url,
vector_extension=config.vector_extension,
schema=schema,
)
for schema in schemas:
ensure_text_search_extension(
resolved_url,
text_search_extension=config.text_search_extension,
pg_search_tokenizer=config.text_search_extension_pg_search_tokenizer,
schema=schema,
)
return schemas
@@ -313,18 +326,6 @@ def run_db_migration(
"--embedding-dimension",
help="Expected embedding dimension to enforce after migrations. Omit to skip dimension sync.",
),
skip_extension_reconcile: bool = typer.Option(
False,
"--skip-extension-reconcile",
help=(
"Skip the post-migration vector / text-search index reconcile. This step only does "
"work when the configured backend (HINDSIGHT_API_VECTOR_EXTENSION / "
"HINDSIGHT_API_TEXT_SEARCH_EXTENSION) differs from a schema's existing indexes — a "
"rare, operator-driven change. Skipping it makes a no-change re-migration over many "
"tenant schemas much faster. Only use when you have NOT changed the backend; a "
"backend change still needs a normal run to reshape the indexes."
),
),
):
"""Run database migrations to the latest version."""
config = HindsightConfig.from_env()
@@ -338,8 +339,6 @@ def run_db_migration(
typer.echo(f"Running database migrations for schema: {schema}...")
else:
typer.echo("Running database migrations for base schema and all discovered tenant schemas...")
if skip_extension_reconcile:
typer.echo("Skipping post-migration extension reconcile (--skip-extension-reconcile).")
schemas = asyncio.run(
_run_migration(
@@ -347,7 +346,6 @@ def run_db_migration(
schema=schema,
base_schema=config.database_schema,
embedding_dimension=embedding_dimension,
ensure_extensions=not skip_extension_reconcile,
)
)
@@ -1,105 +0,0 @@
"""Add a composite index on memory_links(bank_id, link_type) (PostgreSQL).
``bank_id`` was added to ``memory_links`` in ``c5d6e7f8a9b0`` precisely so that
bank-scoped reads (e.g. the stats endpoint) could filter on the link table
directly instead of joining ``memory_units`` — that JOIN took 18+ seconds on
banks with millions of links. The column landed without an index, so every
``bank_id = $1`` predicate still falls back to a sequential scan over the whole
table.
This adds the missing btree. It is composite on ``(bank_id, link_type)`` rather
than ``bank_id`` alone because the hot query is the stats endpoint's
``SELECT link_type, COUNT(*) ... WHERE bank_id = $1 GROUP BY link_type``: a
``(bank_id, link_type)`` index serves that filter, grouping and count as an
index-only scan, never touching the heap, whereas a ``bank_id``-only index would
still have to read every matching row to recover ``link_type``. ``link_type`` is
low-cardinality (only ``temporal``/``semantic``/``caused_by`` are written —
entity edges were dropped in ``e9b2c7d1f3a4``), so the trailing column adds
little to the index size while removing the heap fetch.
The Oracle baseline (``o1a2b3c4d5e6``) already creates ``idx_ml_bank_id`` on
``memory_links(bank_id)``; that single-column index already covers Oracle's
bank-scoped filter, so the Oracle slot here is intentionally absent and only the
PostgreSQL dialect gets the composite index.
``memory_links`` can hold tens of millions of rows, so the index is built
CONCURRENTLY to avoid taking a write lock on the table. CONCURRENTLY cannot run
inside a transaction block, so the statement runs in an ``autocommit_block()``;
``IF NOT EXISTS`` keeps it idempotent across retries and re-migrated tenant
schemas. A CONCURRENTLY build interrupted partway (lock conflict, disk
pressure, signal) leaves the index behind as *invalid*; ``IF NOT EXISTS`` would
then skip over it forever, so the upgrade first drops any invalid leftover of
this name before (re)creating it.
Revision ID: 2071c7518f88
Revises: a1d3f5b7c9e2
Create Date: 2026-06-16
"""
from collections.abc import Sequence
from alembic import context, op
from sqlalchemy import text
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "2071c7518f88"
down_revision: str | Sequence[str] | None = "a1d3f5b7c9e2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_INDEX_NAME = "idx_memory_links_bank_id_link_type"
def _get_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
bind = op.get_bind()
# `or None` collapses an unset option and an explicit empty string into NULL
# so the COALESCE below falls back to current_schema() in both cases.
target_schema = context.config.get_main_option("target_schema") or None
schema = _get_schema_prefix()
# CREATE INDEX CONCURRENTLY cannot run inside a transaction block; the
# autocommit_block runs each statement outside Alembic's migration
# transaction.
with op.get_context().autocommit_block():
# A CONCURRENTLY build that errored on a previous run leaves an INVALID
# index of this name behind. `CREATE INDEX ... IF NOT EXISTS` would see
# that relation and skip, so bank_id queries would keep seq-scanning.
# Drop only the invalid leftover — never a healthy index — so the retry
# actually rebuilds a usable one.
leftover_invalid = bind.execute(
text(
"SELECT NOT i.indisvalid "
"FROM pg_class c "
"JOIN pg_index i ON c.oid = i.indexrelid "
"JOIN pg_namespace n ON c.relnamespace = n.oid "
"WHERE c.relname = :index_name "
" AND n.nspname = COALESCE(:target_schema, current_schema())"
),
{"index_name": _INDEX_NAME, "target_schema": target_schema},
).scalar()
if leftover_invalid:
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}{_INDEX_NAME}")
# IF NOT EXISTS keeps the create idempotent across retries and schemas.
op.execute(f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {_INDEX_NAME} ON {schema}memory_links(bank_id, link_type)")
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
with op.get_context().autocommit_block():
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}{_INDEX_NAME}")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,85 +0,0 @@
"""Repair: widen the remaining live ``bank_id`` columns from VARCHAR(64) to TEXT on PostgreSQL.
Follow-up to ``c3e5a7b9d1f4`` (issue #2106), which widened the two *history*
tables (``observation_history``, ``mental_model_history``) to ``TEXT`` after the
narrow ``VARCHAR(64)`` declaration bricked startup. The same VARCHAR(64) / TEXT
inconsistency still affects the live tables that store a user-supplied
``bank_id``:
* ``directives`` -- created VARCHAR(64) in ``p1k2l3m4n5o6``
* ``mental_models`` -- VARCHAR(64) (origin ``pinned_reflections`` in
``n9i0j1k2l3m4``; recreated in ``h3c4d5e6f7g8``)
``mental_model_versions`` is intentionally *not* widened here: it is created in
``j5e6f7g8h9i0`` but dropped (``DROP TABLE ... CASCADE``) in ``o0j1k2l3m4n5`` and
never recreated on the upgrade path, so it does not exist at head. Issuing
``ALTER TABLE mental_model_versions ...`` would raise ``UndefinedTable`` and --
because migrations run inside the lifespan-startup transaction -- roll the whole
migration back, bricking the API. (It is unrelated to the live
``mental_model_history`` table widened by ``c3e5a7b9d1f4``.)
``banks.bank_id`` is ``TEXT`` (unbounded), so a deployment can create a bank
whose id exceeds 64 chars -- the 78-char hierarchical org-unit shape reported in
issue #2106 -- and the bank insert succeeds. The next write that propagates that
id (``create_directive``, ``create_mental_model`` / consolidation, or
mental-model versioning) then aborts with::
psycopg2.errors.StringDataRightTruncation: value too long for type
character varying(64)
i.e. a 500 on core write endpoints, instead of the startup brick that
``c3e5a7b9d1f4`` already repaired.
``ALTER COLUMN ... TYPE TEXT`` is a no-op on a column that is already ``TEXT``,
so every upgrade path converges on ``TEXT``. These tables are per-tenant (they
live in each tenant schema, not ``public``), so this runs for every migrated
schema via the search-path-aware prefix -- the same mechanism as
``c3e5a7b9d1f4``.
PostgreSQL only: these tables are created by PostgreSQL-only migrations
(``run_for_dialect(pg=...)``); on Oracle they are absent or already
``VARCHAR2(256)`` (consistent, never truncates), so the Oracle slot is
intentionally absent -- mirroring ``c3e5a7b9d1f4``.
Revision ID: a1d3f5b7c9e2
Revises: c3e5a7b9d1f4
Create Date: 2026-06-13
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a1d3f5b7c9e2"
down_revision: str | Sequence[str] | None = "c3e5a7b9d1f4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}directives ALTER COLUMN bank_id TYPE TEXT")
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN bank_id TYPE TEXT")
def _pg_downgrade() -> None:
# No-op: narrowing back to VARCHAR(64) could truncate real data and would
# re-introduce the bug this migration repairs. The column types are owned by
# the migrations that created the tables.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,52 +0,0 @@
"""Add managed flag to knowledge_pages.
The knowledge base is managed by clients (CRUD over folders/pages). ``managed``
lets a client tag a node as system-owned vs. hand-authored; it carries no
server-side behaviour.
Revision ID: a5b6c7d8e9f0
Revises: a9b8c7d6e5f4
Create Date: 2026-06-26
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a5b6c7d8e9f0"
down_revision: str | Sequence[str] | None = "a9b8c7d6e5f4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"ALTER TABLE {schema}knowledge_pages ADD COLUMN IF NOT EXISTS managed BOOLEAN NOT NULL DEFAULT false")
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"ALTER TABLE {schema}knowledge_pages DROP COLUMN IF EXISTS managed")
def _oracle_upgrade() -> None:
op.execute("ALTER TABLE knowledge_pages ADD (managed NUMBER(1) DEFAULT 0 NOT NULL)")
def _oracle_downgrade() -> None:
op.execute("ALTER TABLE knowledge_pages DROP COLUMN managed")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -62,7 +62,7 @@ def _pg_upgrade() -> None:
CREATE TABLE IF NOT EXISTS {schema}mental_model_history (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
mental_model_id VARCHAR(64) NOT NULL,
bank_id TEXT NOT NULL,
bank_id VARCHAR(64) NOT NULL,
content JSONB NOT NULL,
changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
FOREIGN KEY (mental_model_id, bank_id)
@@ -83,7 +83,7 @@ def _pg_upgrade() -> None:
CREATE TABLE IF NOT EXISTS {schema}observation_history (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
observation_id UUID NOT NULL,
bank_id TEXT NOT NULL,
bank_id VARCHAR(64) NOT NULL,
content JSONB NOT NULL,
changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
FOREIGN KEY (observation_id)
@@ -1,110 +0,0 @@
"""Add knowledge_pages table (knowledge-base hierarchy).
The knowledge base organizes synthesized mental models into a navigable tree of
**folders** and **pages**. A page references the mental model that holds its
content (``mental_model_id``); a folder is a pure container (``mental_model_id``
NULL). Hierarchy is a single self-referential ``parent_id`` so folders can nest
arbitrarily. Content stays in ``mental_models`` — this table is metadata + tree
structure only.
Revision ID: a9b8c7d6e5f4
Revises: b57a7c9e0d13
Create Date: 2026-06-25
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "a9b8c7d6e5f4"
down_revision: str | Sequence[str] | None = "b57a7c9e0d13"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# parent_id self-FK cascades so deleting a folder row removes its whole
# subtree of rows in one shot. The mental_model FK is composite (matches the
# mental_models (id, bank_id) PK) and cascades too, so deleting a page's
# mental model removes the page row — folders skip the FK because a NULL
# column in a composite FK is not enforced (MATCH SIMPLE).
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}knowledge_pages (
id VARCHAR(64) NOT NULL,
bank_id TEXT NOT NULL,
parent_id VARCHAR(64),
kind VARCHAR(16) NOT NULL,
name TEXT NOT NULL,
mental_model_id VARCHAR(64),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT pk_knowledge_pages PRIMARY KEY (id),
CONSTRAINT ck_knowledge_pages_kind CHECK (kind IN ('folder', 'page')),
CONSTRAINT fk_kp_bank FOREIGN KEY (bank_id)
REFERENCES {schema}banks(bank_id) ON DELETE CASCADE,
CONSTRAINT fk_kp_parent FOREIGN KEY (parent_id)
REFERENCES {schema}knowledge_pages(id) ON DELETE CASCADE,
CONSTRAINT fk_kp_mm FOREIGN KEY (mental_model_id, bank_id)
REFERENCES {schema}mental_models(id, bank_id) ON DELETE CASCADE
)
"""
)
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_kp_bank_parent ON {schema}knowledge_pages (bank_id, parent_id, sort_order)"
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_kp_bank_parent")
op.execute(f"DROP TABLE IF EXISTS {schema}knowledge_pages")
def _oracle_upgrade() -> None:
op.execute(
"""
CREATE TABLE IF NOT EXISTS knowledge_pages (
id VARCHAR2(64) NOT NULL,
bank_id VARCHAR2(256) NOT NULL,
parent_id VARCHAR2(64),
kind VARCHAR2(16) NOT NULL,
name CLOB NOT NULL,
mental_model_id VARCHAR2(64),
sort_order NUMBER DEFAULT 0 NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT pk_knowledge_pages PRIMARY KEY (id),
CONSTRAINT ck_knowledge_pages_kind CHECK (kind IN ('folder', 'page')),
CONSTRAINT fk_kp_bank FOREIGN KEY (bank_id)
REFERENCES banks(bank_id) ON DELETE CASCADE,
CONSTRAINT fk_kp_parent FOREIGN KEY (parent_id)
REFERENCES knowledge_pages(id) ON DELETE CASCADE,
CONSTRAINT fk_kp_mm FOREIGN KEY (mental_model_id, bank_id)
REFERENCES mental_models(id, bank_id) ON DELETE CASCADE
)
"""
)
op.execute("CREATE INDEX idx_kp_bank_parent ON knowledge_pages (bank_id, parent_id, sort_order)")
def _oracle_downgrade() -> None:
op.execute("DROP TABLE knowledge_pages CASCADE CONSTRAINTS")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -1,61 +0,0 @@
"""Add bank_stats_cache table for distributed get_bank_stats caching
Revision ID: b57a7c9e0d13
Revises: c3f7a1b9d2e4
Create Date: 2026-07-01
get_bank_stats aggregates over memory_links / unit_entities — a multi-second scan
on banks with millions of rows. The result was cached per-process (in-memory), so
every API worker recomputed it once per TTL and the first caller after expiry
stalled. This table backs a shared, cross-process TTL cache: one worker's compute
is written here and served to all the others.
PostgreSQL only. Oracle keeps the in-process cache (the runtime picks the backing
store by dialect), so the Oracle upgrade slot is intentionally absent.
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "b57a7c9e0d13"
down_revision: str | Sequence[str] | None = "c3f7a1b9d2e4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
# One row per bank: payload is the full get_bank_stats result, computed_at
# drives logical TTL expiry. Rows are overwritten in place (ON CONFLICT), so
# the table never grows beyond the number of banks and needs no purge job.
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}bank_stats_cache (
bank_id TEXT PRIMARY KEY,
payload JSONB NOT NULL,
computed_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
def _pg_downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP TABLE IF EXISTS {schema}bank_stats_cache")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade) # oracle slot intentionally absent → no-op
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,71 +0,0 @@
"""Unique page name per folder in knowledge_pages.
The folder curator can fire concurrently (folder-create trigger + the
post-consolidation sweep), and an in-process lock can't serialize runs that
execute in different threads/loops. A partial unique index on
(bank_id, parent, lower(name)) for pages makes duplicate-named pages in the same
folder impossible at the DB level — the second concurrent insert fails and the
curator treats it as "already exists".
PostgreSQL only: the Oracle ``name`` column is a CLOB and cannot back a
functional unique index; Oracle relies on the in-process serialization instead.
Revision ID: c3d4e5f6a7b8
Revises: a5b6c7d8e9f0
Create Date: 2026-06-26
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c3d4e5f6a7b8"
down_revision: str | Sequence[str] | None = "a5b6c7d8e9f0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# First drop any pre-existing duplicate pages (created by the racy curator
# before this guard existed), keeping the earliest row of each duplicate set,
# so the unique index can be built. Their backing mental models are left in
# place (harmless orphans).
op.execute(
f"""
DELETE FROM {schema}knowledge_pages a
USING {schema}knowledge_pages b
WHERE a.kind = 'page' AND b.kind = 'page'
AND a.bank_id = b.bank_id
AND COALESCE(a.parent_id, '') = COALESCE(b.parent_id, '')
AND lower(a.name) = lower(b.name)
AND a.ctid > b.ctid
"""
)
# COALESCE(parent_id, '') so root-level pages (NULL parent) are also unique by
# name — NULLs would otherwise compare distinct and allow duplicates.
op.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_kp_folder_pagename "
f"ON {schema}knowledge_pages (bank_id, COALESCE(parent_id, ''), lower(name)) "
"WHERE kind = 'page'"
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}uq_kp_folder_pagename")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade) # oracle slot intentionally absent (CLOB name)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,75 +0,0 @@
"""Repair: widen ``*_history.bank_id`` from VARCHAR(64) to TEXT on PostgreSQL.
The original split-history migration (``a7b8c9d0e1f2``) declared
``observation_history.bank_id`` and ``mental_model_history.bank_id`` as
``VARCHAR(64)`` on PostgreSQL. But ``memory_units.bank_id`` — the backfill
source for observations — is ``TEXT`` (unbounded), as are ``banks``,
``documents`` and ``entities``. Any deployment whose ``bank_id`` exceeds 64
characters aborts the backfill ``INSERT`` with::
psycopg2.errors.StringDataRightTruncation: value too long for type
character varying(64)
Because the migration runs in ``lifespan`` startup inside a transaction, the
whole migration rolls back and the API never comes up — unrecoverable from the
running container. See https://github.com/vectorize-io/hindsight/issues/2106.
``a7b8c9d0e1f2`` itself has been corrected to create the column as ``TEXT``,
which unblocks deployments that *failed* (the migration rolled back, so it
re-runs the fixed DDL). This forward migration covers deployments that already
*succeeded* with the narrow ``VARCHAR(64)`` column — where editing
``a7b8c9d0e1f2`` has no effect because it will not re-run — by widening the
column in place. ``ALTER COLUMN ... TYPE TEXT`` is a no-op on a column that is
already ``TEXT`` (fresh installs and re-run failures), so every upgrade path
converges on ``TEXT``.
The history tables are per-tenant (they live in each tenant schema, not
``public``), so this runs for every migrated schema via the search-path-aware
prefix — unlike the shared-``public`` routines repaired in ``b2d4f6a8c1e3``.
PostgreSQL only. On Oracle both ``memory_units.bank_id`` and the history
``bank_id`` columns are already ``VARCHAR2(256)`` (consistent, never
truncates), so the Oracle slot is intentionally absent.
Revision ID: c3e5a7b9d1f4
Revises: c9a1b2d3e4f5
Create Date: 2026-06-10
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c3e5a7b9d1f4"
down_revision: str | Sequence[str] | None = "c9a1b2d3e4f5"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}observation_history ALTER COLUMN bank_id TYPE TEXT")
op.execute(f"ALTER TABLE {schema}mental_model_history ALTER COLUMN bank_id TYPE TEXT")
def _pg_downgrade() -> None:
# No-op: narrowing back to VARCHAR(64) could truncate real data and would
# re-introduce the bug this migration repairs. The column type is owned by
# ``a7b8c9d0e1f2``'s lifecycle.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,144 +0,0 @@
"""Backfill search_vector for native-backend observations.
Observations created or updated by the consolidator landed with a NULL
``search_vector`` under the ``native`` text-search backend: the
single-row INSERT/UPDATE paths in ``consolidator.py`` never populated the
tsvector (only the batch raw-fact path in ``ops_postgresql.insert_facts_batch``
did). Those observations were therefore invisible to the BM25 retrieval arm
until they were re-written by a later consolidation pass. The writer is fixed
in the same change set (all four consolidator sites now call
``to_tsvector($lang, COALESCE(text, ''))``); this migration repairs the
historical residue so existing observations become BM25-searchable without a
re-ingest.
Scope mirrors the writer fix exactly:
* Only the ``native`` backend is touched. The gate is the column *type*:
under ``native`` ``search_vector`` is a regular (non-generated) tsvector
column; under ``vchord`` it is a ``bm25vector`` and under
``pg_textsearch`` / ``pgroonga`` / ``pg_search`` it is a dummy ``text``
column. ``_is_regular_tsvector`` is true only for ``native``, so every
other backend is a no-op.
* The tsvector is built from the observation's own ``text`` only — matching
the consolidator INSERT/UPDATE paths (entity / source / temporal signals
are intentionally excluded; the other retrieval arms cover those).
* Only ``fact_type = 'observation'`` rows with a NULL ``search_vector`` are
rewritten. Raw facts already carry a populated tsvector, and the
``IS NULL`` predicate makes the migration idempotent and re-runnable.
The configured ``HINDSIGHT_API_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE`` is used
so backfilled rows are lexically identical to newly-created observations. The
value is validated as a PG identifier (mirroring
``HindsightConfig.validate``) before being embedded as a SQL literal.
This is a single UPDATE per schema: it locks the targeted observation rows for
its duration. It is one-time and only touches unpopulated rows, so subsequent
online writes (which now carry the tsvector via the writer fix) are unaffected.
Oracle slot is intentionally absent: the consolidator INSERT/UPDATE paths that
this repairs are PostgreSQL-specific (``ops_postgresql``), and the native
tsvector ``search_vector`` column only exists on PostgreSQL. There is no Oracle
residue to repair.
Revision ID: c3f7a1b9d2e4
Revises: f4d1c2b3a5e6
Create Date: 2026-06-29
"""
import os
import re
from collections.abc import Sequence
from alembic import context, op
from sqlalchemy import Connection, text
from hindsight_api.alembic._dialect import run_for_dialect
from hindsight_api.config import (
DEFAULT_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE,
ENV_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE,
)
revision: str = "c3f7a1b9d2e4"
down_revision: str | Sequence[str] | None = "f4d1c2b3a5e6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# Matches HindsightConfig.validate(): a tsvector regconfig name embedded as a
# SQL literal must be a bare PG identifier.
_PG_IDENTIFIER = re.compile(r"[a-zA-Z_][a-zA-Z0-9_]*")
def _schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _schema_name() -> str:
return (context.config.get_main_option("target_schema") or "public").strip('"')
def _native_language() -> str:
"""Configured native tsvector language, validated as a PG identifier."""
lang = os.getenv(
ENV_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE,
DEFAULT_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE,
)
if not _PG_IDENTIFIER.fullmatch(lang):
return DEFAULT_TEXT_SEARCH_EXTENSION_NATIVE_LANGUAGE
return lang
def _is_regular_tsvector(conn: Connection, schema: str, table: str) -> bool:
"""True iff ``schema.table.search_vector`` is a non-generated tsvector column.
This is the ``native`` backend signature. ``vchord`` (bm25vector) and
``pg_textsearch`` / ``pgroonga`` / ``pg_search`` (dummy text column) all
fail this check, so the backfill is a no-op for them.
"""
row = conn.execute(
text(
"""
SELECT is_generated, udt_name
FROM information_schema.columns
WHERE table_schema = :schema
AND table_name = :table
AND column_name = 'search_vector'
"""
),
{"schema": schema, "table": table},
).fetchone()
if not row:
return False
is_generated, udt_name = row[0], row[1]
return udt_name == "tsvector" and is_generated != "ALWAYS"
def _pg_upgrade() -> None:
conn = op.get_bind()
schema_name = _schema_name()
if not _is_regular_tsvector(conn, schema_name, "memory_units"):
# Non-native backend (or column absent) — nothing to backfill.
return
schema_prefix = _schema_prefix()
lang = _native_language()
op.execute(
f"""
UPDATE {schema_prefix}memory_units
SET search_vector = to_tsvector('{lang}'::regconfig, COALESCE(text, ''))
WHERE fact_type = 'observation' AND search_vector IS NULL
"""
)
def _pg_downgrade() -> None:
# No-op: backfilled rows are indistinguishable from observations that were
# populated by the post-fix writer, and reverting either to NULL would
# re-break BM25 retrieval. The column simply stays populated.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,158 +0,0 @@
"""Make maintenance routines resilient to schemas that vanish mid-scan.
``public.banks_needing_consolidation()`` and
``public.schemas_with_expired_rows(...)`` snapshot the set of schemas owning a
target table from ``pg_class`` and then run a dynamic query against each schema
in turn. That is a time-of-check/time-of-use race: a schema (or its tables) can
be dropped — a tenant being deleted, or a tenant migration that recreates
tables — between the snapshot and the per-schema query, which then aborts the
whole routine with::
relation "<schema>.memory_units" does not exist
relation "<schema>.audit_log" does not exist
In the test suite this surfaces as cross-worker contamination: the multi-tenant
maintenance test creates and drops ~100 ``mt<hash>_NNN`` schemas while
``test_maintenance_routines`` (on another xdist worker, same DB) calls the
routines. In production the background maintenance loop hits the same race when
a tenant is removed or mid-migration.
Wrap each per-schema query in its own ``BEGIN ... EXCEPTION`` block so a schema
that disappears (``undefined_table`` / ``invalid_schema_name`` /
``undefined_column``) is skipped instead of aborting the scan. The routines stay
``CREATE OR REPLACE`` and PostgreSQL-only, and are (re)installed only on the run
that targets the shared ``public`` schema — same gating as the original
install (``e5f6a7b8c9d0``) and its repair (``b2d4f6a8c1e3``).
Revision ID: c7e9f1a3b5d2
Revises: e1f2a3b4c5d6
Create Date: 2026-06-19
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c7e9f1a3b5d2"
down_revision: str | Sequence[str] | None = "e1f2a3b4c5d6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _should_install_public_routines(target_schema: str | None) -> bool:
"""True for the run that must (re)create the shared ``public.*`` routines.
The routines physically live in ``public``, so they are installed exactly
once — on the base run (no ``target_schema``) or the run that explicitly
targets ``public``. Mirrors ``b2d4f6a8c1e3``.
"""
return not target_schema or target_schema == "public"
def _pg_upgrade() -> None:
if not _should_install_public_routines(context.config.get_main_option("target_schema")):
return
# Same body as b2d4f6a8c1e3, but each per-schema query runs in its own
# subtransaction so a schema dropped mid-scan is skipped, not fatal.
op.execute(
"""
CREATE OR REPLACE FUNCTION public.banks_needing_consolidation()
RETURNS TABLE(schema_name text, bank_id text)
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
BEGIN
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'memory_units' AND c.relkind = 'r'
LOOP
BEGIN
RETURN QUERY EXECUTE format($q$
SELECT %1$L::text, m.bank_id
FROM %1$I.memory_units m
JOIN %1$I.banks b ON b.bank_id = m.bank_id
WHERE m.consolidated_at IS NULL
AND m.consolidation_failed_at IS NULL
AND m.fact_type IN ('experience', 'world')
AND COALESCE(b.config -> 'enable_auto_consolidation', 'true'::jsonb) <> 'false'::jsonb
AND NOT EXISTS (
SELECT 1 FROM %1$I.async_operations o
WHERE o.bank_id = m.bank_id
AND o.operation_type = 'consolidation'
AND o.status IN ('pending', 'processing')
)
GROUP BY m.bank_id
$q$, sch);
EXCEPTION
-- Schema or its tables vanished between the pg_class
-- snapshot and this query (tenant dropped or migrating).
WHEN undefined_table OR invalid_schema_name OR undefined_column THEN
CONTINUE;
END;
END LOOP;
END;
$fn$;
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION public.schemas_with_expired_rows(
p_table text, p_ts_col text, p_days int
)
RETURNS SETOF text
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
has_expired boolean;
BEGIN
IF p_days IS NULL OR p_days <= 0 THEN
RETURN;
END IF;
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = p_table AND c.relkind = 'r'
LOOP
BEGIN
EXECUTE format(
'SELECT EXISTS (SELECT 1 FROM %I.%I WHERE %I < NOW() - make_interval(days => $1))',
sch, p_table, p_ts_col
) INTO has_expired USING p_days;
EXCEPTION
-- Schema or its table vanished mid-scan; skip it.
WHEN undefined_table OR invalid_schema_name OR undefined_column THEN
CONTINUE;
END;
IF has_expired THEN
RETURN NEXT sch;
END IF;
END LOOP;
END;
$fn$;
"""
)
def _pg_downgrade() -> None:
# No-op: e5f6a7b8c9d0 owns these functions' lifecycle and drops them on its
# own downgrade. This migration only re-installs them (the resilient body is
# a strict superset of the previous behaviour), so there is nothing to undo
# without racing that migration's DROP.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,108 +0,0 @@
"""Add invalidated_memory_units table for curation (edit/invalidate).
Curation keeps the recall hot-path (``memory_units``) clean by *moving*
invalidated facts into a sibling archive table rather than flagging them in
place. If a row is in ``memory_units`` it is live; if it is in
``invalidated_memory_units`` it has been retired. Recall/consolidation/graph
queries never need a state predicate — the rows simply aren't there.
The archive mirrors ``memory_units`` column-for-column — except ``embedding``,
which it never keeps: the archive is cold storage, never a recall surface, and
revert recomputes the embedding from the unit's text/dates/entities. Keeping no
archive vector also means a later embedding-model switch (which re-dimensions
``memory_units``) can't trip a dimension mismatch on the move (#2209). Plus:
- ``invalidation_reason`` optional free text recorded on invalidate
- ``invalidated_at`` when it was retired
- ``entity_ids`` snapshot of the unit's entity associations, so revert
can restore them (``unit_entities`` is cascade-deleted
when the live row is removed)
This migration also adds ``edited_at`` to ``memory_units``: set whenever a user
edits a memory's fields (text, context, dates, fact_type, entities) via curation.
NULL means never manually modified; a non-NULL value answers "has the user ever
changed this?" with the time of the last edit (distinct from ``updated_at``,
which background operations also bump). It is added to ``memory_units`` *before*
the archive is cloned below, so the archive inherits the column and the marker
travels with a fact when it is invalidated.
Revision ID: c9a1b2d3e4f5
Revises: b2d4f6a8c1e3
Create Date: 2026-06-03
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "c9a1b2d3e4f5"
down_revision: str | Sequence[str] | None = "b2d4f6a8c1e3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
# Add edited_at to the live table FIRST so the archive's LIKE clone below
# inherits it (keeps the two tables column-for-column identical for round-trip).
op.execute(f"ALTER TABLE {schema}memory_units ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ")
# LIKE ... INCLUDING DEFAULTS clones every memory_units column (incl.
# edited_at) so an invalidated row can move back verbatim. We deliberately
# omit indexes/constraints — the archive is cold storage, not a recall
# surface; only the lookups below need indexing.
op.execute(
f"CREATE TABLE IF NOT EXISTS {schema}invalidated_memory_units (LIKE {schema}memory_units INCLUDING DEFAULTS)"
)
# ...then drop the inherited embedding: the archive never stores one (revert
# recomputes it), so it isn't created here only to be dropped again later by
# d4f6a8c2e1b3. That migration still runs as a no-op (DROP ... IF EXISTS) on
# fresh DBs and does the real drop on DBs created before this column was removed.
op.execute(f"ALTER TABLE {schema}invalidated_memory_units DROP COLUMN IF EXISTS embedding")
op.execute(
f"ALTER TABLE {schema}invalidated_memory_units "
f"ADD COLUMN IF NOT EXISTS invalidation_reason TEXT, "
f"ADD COLUMN IF NOT EXISTS invalidated_at TIMESTAMPTZ DEFAULT now(), "
f"ADD COLUMN IF NOT EXISTS entity_ids UUID[]"
)
op.execute(f"CREATE UNIQUE INDEX IF NOT EXISTS idx_invalidated_mu_id ON {schema}invalidated_memory_units (id)")
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_invalidated_mu_bank "
f"ON {schema}invalidated_memory_units (bank_id, invalidated_at)"
)
# Deleting a document (or bank) should clear its archived facts too, mirroring
# the memory_units → documents cascade.
op.execute(
f"""
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'invalidated_mu_document_fkey') THEN
ALTER TABLE {schema}invalidated_memory_units
ADD CONSTRAINT invalidated_mu_document_fkey
FOREIGN KEY (document_id, bank_id)
REFERENCES {schema}documents(id, bank_id) ON DELETE CASCADE;
END IF; END $$;
"""
)
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
# Drops the archive (and its inherited edited_at) wholesale, then removes
# edited_at from the live table.
op.execute(f"DROP TABLE IF EXISTS {schema}invalidated_memory_units")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS edited_at")
def upgrade() -> None:
# PG-only: Oracle gets the table from the baseline snapshot, matching the
# convention used by sibling column/index migrations in this tree.
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,93 +0,0 @@
"""Drop the embedding column from the curation archive (invalidated_memory_units).
The archive is cold storage, never a recall surface, so it has no business
keeping an embedding. Earlier curation code copied the live row's embedding into
``invalidated_memory_units`` on invalidate; the engine now leaves it out on
invalidate and recomputes it on revert, so the column is dead weight.
Dropping it makes "the archive holds no embedding" a schema-enforced invariant
rather than a convention the move queries have to honour, and removes a latent
failure mode (#2209): after an embedding-model switch the live tables are
re-dimensioned but the archive was not, so a stale old-dimension embedding in
the archive tripped a vector-dimension mismatch on the INSERT … SELECT
round-trip. With no column at all, there is nothing to mismatch.
The creation sites no longer add the column (the PG ``LIKE`` clone in
c9a1b2d3e4f5 drops it; the Oracle baseline omits it), so on a fresh database
this migration is a no-op (DROP ... IF EXISTS / Oracle ORA-00904 swallow). It
does the real work on databases created before the column was removed there.
DROP COLUMN is a metadata-only operation on both PostgreSQL and Oracle 23ai (no
table rewrite), so it is cheap even across many tenant schemas. The downgrade
re-adds an unconstrained vector column (any dimension) — empty, since the
embeddings are intentionally discarded.
Revision ID: d4f6a8c2e1b3
Revises: a1d3f5b7c9e2
Create Date: 2026-06-15
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "d4f6a8c2e1b3"
down_revision: str | Sequence[str] | None = "a1d3f5b7c9e2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_schema_prefix() -> str:
"""Schema-qualifier for raw SQL on PG (multi-tenant search_path)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _pg_schema_prefix()
op.execute(f"ALTER TABLE {schema}invalidated_memory_units DROP COLUMN IF EXISTS embedding")
def _pg_downgrade() -> None:
schema = _pg_schema_prefix()
# Unconstrained `vector` (no dimension) so the re-added column accepts any
# model's embeddings; it comes back empty regardless.
op.execute(f"ALTER TABLE {schema}invalidated_memory_units ADD COLUMN IF NOT EXISTS embedding vector")
def _oracle_upgrade() -> None:
# Oracle has no `DROP COLUMN IF EXISTS`; swallow ORA-00904 (column does not
# exist) so the migration is idempotent and safe on a fresh schema whose
# baseline already omits the column.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units DROP COLUMN embedding';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -904 THEN RAISE; END IF;
END;
"""
)
def _oracle_downgrade() -> None:
# Swallow ORA-01430 (column already exists) for idempotency.
op.execute(
"""
BEGIN
EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units ADD (embedding VECTOR)';
EXCEPTION WHEN OTHERS THEN
IF SQLCODE != -1430 THEN RAISE; END IF;
END;
"""
)
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade)
@@ -1,39 +0,0 @@
"""Merge two divergent migration heads.
``d4f6a8c2e1b3`` (drop the curation-archive embedding column) and
``2071c7518f88`` (add the memory_links(bank_id, link_type) index) were authored
in parallel off the same parent (``a1d3f5b7c9e2``) and merged independently,
leaving the DAG with two heads. This is a no-op merge that re-unifies them so
``alembic upgrade head`` is unambiguous again (enforced by
``tests/test_alembic_dag.py::test_single_head``).
Revision ID: e1f2a3b4c5d6
Revises: d4f6a8c2e1b3, 2071c7518f88
Create Date: 2026-06-16
"""
from collections.abc import Sequence
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "e1f2a3b4c5d6"
down_revision: str | Sequence[str] | None = ("d4f6a8c2e1b3", "2071c7518f88")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_upgrade() -> None:
# Pure DAG merge — both parents already applied their schema changes.
pass
def _pg_downgrade() -> None:
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -1,110 +0,0 @@
"""Add server-side routine for cron-scheduled mental model refresh.
Installs ``public.mental_models_with_cron()`` — a discovery routine that returns
every mental model carrying a non-empty ``trigger->>'refresh_cron'`` across all
tenant schemas in one round-trip (the same per-schema scan as the other
maintenance routines from ``e5f6a7b8c9d0``). The maintenance loop evaluates each
candidate's cron expression in Python (``croniter``) against ``last_refreshed_at``
to decide whether a scheduled refresh is due — cron arithmetic isn't expressible
in plain SQL — and only the cron *candidate set* is discovered here.
Models that already have a ``refresh_mental_model`` operation pending/processing
are excluded so a slow refresh isn't double-queued (mirrors the in-flight guard
in ``banks_needing_consolidation``). Each per-schema query runs in its own
``BEGIN ... EXCEPTION`` subtransaction so a schema dropped mid-scan (tenant
deletion / migration) is skipped, not fatal — same resilience as
``c7e9f1a3b5d2``.
Read-only (STABLE) discovery routine — the caller performs the refresh enqueue —
so installing it never mutates data. PostgreSQL only: the worker poller and the
maintenance loop are PG-only (Oracle slot intentionally absent, mirroring
``e5f6a7b8c9d0``). The routine lives in ``public`` and is CREATE OR REPLACE, so
it is installed exactly once (base / ``public`` run) to avoid the
``tuple concurrently updated`` race on concurrent per-tenant runs.
Revision ID: f4d1c2b3a5e6
Revises: c7e9f1a3b5d2
Create Date: 2026-06-23
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "f4d1c2b3a5e6"
down_revision: str | Sequence[str] | None = "c7e9f1a3b5d2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _should_install_public_routines(target_schema: str | None) -> bool:
"""True for the run that must (re)create the shared ``public.*`` routine.
The routine physically lives in ``public``, so it is installed exactly once —
on the base run (no ``target_schema``) or the run that explicitly targets
``public``. Mirrors ``c7e9f1a3b5d2``.
"""
return not target_schema or target_schema == "public"
def _pg_upgrade() -> None:
if not _should_install_public_routines(context.config.get_main_option("target_schema")):
return
op.execute(
"""
CREATE OR REPLACE FUNCTION public.mental_models_with_cron()
RETURNS TABLE(schema_name text, bank_id text, mental_model_id text,
refresh_cron text, last_refreshed_at timestamptz)
LANGUAGE plpgsql STABLE
AS $fn$
DECLARE
sch text;
BEGIN
FOR sch IN
SELECT n.nspname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'mental_models' AND c.relkind = 'r'
LOOP
BEGIN
RETURN QUERY EXECUTE format($q$
SELECT %1$L::text, mm.bank_id::text, mm.id::text,
mm.trigger->>'refresh_cron', mm.last_refreshed_at
FROM %1$I.mental_models mm
WHERE COALESCE(mm.trigger->>'refresh_cron', '') <> ''
AND NOT EXISTS (
SELECT 1 FROM %1$I.async_operations o
WHERE o.bank_id = mm.bank_id
AND o.operation_type = 'refresh_mental_model'
AND o.status IN ('pending', 'processing')
AND o.task_payload->>'mental_model_id' = mm.id::text
)
$q$, sch);
EXCEPTION
-- Schema or its tables vanished between the pg_class
-- snapshot and this query (tenant dropped or migrating).
WHEN undefined_table OR invalid_schema_name OR undefined_column THEN
CONTINUE;
END;
END LOOP;
END;
$fn$;
"""
)
def _pg_downgrade() -> None:
if not _should_install_public_routines(context.config.get_main_option("target_schema")):
return
op.execute("DROP FUNCTION IF EXISTS public.mental_models_with_cron()")
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -122,7 +122,6 @@ _TABLES: tuple[str, ...] = (
text_signals CLOB,
consolidation_failed_at TIMESTAMP WITH TIME ZONE,
search_vector CLOB,
edited_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT pk_memory_units PRIMARY KEY (id),
@@ -139,50 +138,6 @@ _TABLES: tuple[str, ...] = (
PARTITION BY LIST (bank_id) AUTOMATIC
(PARTITION p_default VALUES ('__default__'))
""",
# Cold archive for curation: invalidated facts are MOVED here out of
# memory_units so the recall hot-path never sees them. Mirrors memory_units
# plus invalidation bookkeeping and an entity-id snapshot for lossless revert.
# No `embedding` column: the archive is cold storage and revert recomputes the
# embedding, so there is no archive vector to fall out of sync with the live
# model's dimension on a model switch (#2209).
"""
CREATE TABLE IF NOT EXISTS invalidated_memory_units (
id RAW(16) NOT NULL,
bank_id VARCHAR2(256) NOT NULL,
document_id VARCHAR2(512),
chunk_id VARCHAR2(512),
text CLOB NOT NULL,
context CLOB,
event_date TIMESTAMP WITH TIME ZONE NOT NULL,
occurred_start TIMESTAMP WITH TIME ZONE,
occurred_end TIMESTAMP WITH TIME ZONE,
mentioned_at TIMESTAMP WITH TIME ZONE,
fact_type VARCHAR2(64) DEFAULT 'world' NOT NULL,
confidence_score BINARY_DOUBLE,
access_count NUMBER(10) DEFAULT 0 NOT NULL,
consolidated_at TIMESTAMP WITH TIME ZONE,
observation_scopes CLOB CONSTRAINT imu_obs_scopes_json CHECK (observation_scopes IS JSON OR observation_scopes IS NULL),
tags CLOB DEFAULT '[]' NOT NULL,
metadata CLOB DEFAULT '{}' NOT NULL
CONSTRAINT imu_metadata_json CHECK (metadata IS JSON),
proof_count NUMBER(10) DEFAULT 1,
source_memory_ids CLOB,
history CLOB DEFAULT '[]'
CONSTRAINT imu_history_json CHECK (history IS JSON OR history IS NULL),
text_signals CLOB,
consolidation_failed_at TIMESTAMP WITH TIME ZONE,
search_vector CLOB,
edited_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
invalidation_reason CLOB,
invalidated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP,
entity_ids CLOB CONSTRAINT imu_entity_ids_json CHECK (entity_ids IS JSON OR entity_ids IS NULL),
CONSTRAINT pk_invalidated_memory_units PRIMARY KEY (id),
CONSTRAINT fk_imu_document FOREIGN KEY (document_id, bank_id)
REFERENCES documents(id, bank_id) ON DELETE CASCADE
)
""",
"""
CREATE TABLE IF NOT EXISTS entities (
id RAW(16) DEFAULT SYS_GUID() NOT NULL,
@@ -16,7 +16,9 @@ retention parameters, retrieval settings, etc.) in Python field name format.
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
from sqlalchemy.dialects.postgresql import JSONB
from hindsight_api.alembic._dialect import run_for_dialect
@@ -1,97 +0,0 @@
"""Client-disconnect detection that works behind ``BaseHTTPMiddleware``.
``Request.is_disconnected()`` is the obvious way to notice an abandoned HTTP
request, but it is silently broken once any ``@app.middleware("http")``
(Starlette ``BaseHTTPMiddleware``) is installed: that middleware runs the route
in a child task behind anyio memory streams, so the ``http.disconnect`` ASGI
event never reaches the route's ``Request``. This app has such middlewares, so
the recall/reflect cancellation in #2122/#2127 never actually fired in
production — the disconnect was never observed.
This pure-ASGI middleware sits *outside* the ``BaseHTTPMiddleware`` layer, where
it still owns the real ``receive`` channel. For the recall and reflect routes it
drains ``receive`` in a background task and trips a :class:`CancellationToken`
the moment ``http.disconnect`` arrives, stashing the token on the ASGI ``scope``.
The route copies that token onto its ``RequestContext`` and the engine checks it
at stage boundaries — so abandoned work stops instead of running to completion.
It only wraps recall/reflect (small JSON bodies); every other request — uploads,
MCP streams, etc. — passes straight through untouched, so there is no buffering
or latency cost elsewhere.
"""
from __future__ import annotations
import asyncio
import contextlib
from collections.abc import Awaitable, Callable, MutableMapping
from typing import Any
from ..cancellation import CancellationToken
# Key under which the per-request CancellationToken is stored on the ASGI scope.
# A dedicated top-level scope key (not scope["state"]) avoids any interaction
# with Starlette's per-request state copying.
SCOPE_CANCELLATION_TOKEN = "hindsight.cancellation_token"
_CLIENT_DISCONNECTED_REASON = "client disconnected"
Scope = MutableMapping[str, Any]
Receive = Callable[[], Awaitable[MutableMapping[str, Any]]]
Send = Callable[[MutableMapping[str, Any]], Awaitable[None]]
def _should_monitor(path: str) -> bool:
"""Only the two long-running, abandon-prone read endpoints need monitoring."""
return path.endswith("/memories/recall") or path.endswith("/reflect")
class ClientDisconnectCancellationMiddleware:
"""Trip a scope-level CancellationToken when the client disconnects.
Must be installed *outside* any ``BaseHTTPMiddleware`` so it owns the real
ASGI ``receive`` channel.
"""
def __init__(self, app: Callable) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http" or not _should_monitor(scope.get("path", "")):
await self.app(scope, receive, send)
return
token = CancellationToken()
scope[SCOPE_CANCELLATION_TOKEN] = token
# The downstream app still needs to read the request body, so we cannot
# simply consume `receive` ourselves. Instead a single pump task drains
# the real channel, forwards every message to a queue the app reads from,
# and trips the token the instant `http.disconnect` shows up — which the
# app would otherwise never pull once it has finished reading the body.
queue: asyncio.Queue = asyncio.Queue()
async def pump() -> None:
while True:
message = await receive()
if message["type"] == "http.disconnect":
token.cancel(_CLIENT_DISCONNECTED_REASON)
await queue.put(message)
return
await queue.put(message)
async def proxied_receive() -> MutableMapping[str, Any]:
return await queue.get()
pump_task = asyncio.create_task(pump())
try:
await self.app(scope, proxied_receive, send)
finally:
pump_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await pump_task
def get_scope_cancellation_token(scope: Scope) -> CancellationToken | None:
"""Return the CancellationToken the middleware attached, if any."""
return scope.get(SCOPE_CANCELLATION_TOKEN)
File diff suppressed because it is too large Load Diff
+1 -22
View File
@@ -9,7 +9,7 @@ from fastmcp import FastMCP
from hindsight_api import MemoryEngine
from hindsight_api import __version__ as HINDSIGHT_VERSION
from hindsight_api.config import DEFAULT_MCP_RECALL_DESCRIPTION, DEFAULT_MCP_RETAIN_DESCRIPTION, _get_raw_config
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.memory_engine import _current_schema
from hindsight_api.extensions import MCPExtension, load_extension
from hindsight_api.extensions.tenant import AuthenticationError
@@ -78,19 +78,6 @@ def get_current_mcp_authenticated() -> bool:
return _current_mcp_authenticated.get()
def _build_mcp_tool_descriptions(extra_instructions: str | None) -> tuple[str | None, str | None]:
"""Return custom retain/recall descriptions when server-level MCP instructions are set."""
if not isinstance(extra_instructions, str):
return None, None
extra_instructions = extra_instructions.strip()
if not extra_instructions:
return None, None
suffix = f"\n\nAdditional instructions: {extra_instructions}"
return DEFAULT_MCP_RETAIN_DESCRIPTION + suffix, DEFAULT_MCP_RECALL_DESCRIPTION + suffix
def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
"""
Create and configure the Hindsight MCP server.
@@ -126,8 +113,6 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
"delete_directive",
"list_memories",
"get_memory",
"update_memory",
"invalidate_memory",
"list_documents",
"get_document",
"delete_document",
@@ -148,10 +133,6 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
allowed = frozenset(global_config.mcp_enabled_tools)
base_tools = (base_tools if base_tools is not None else _ALL_TOOLS) & allowed
retain_description, recall_description = _build_mcp_tool_descriptions(
getattr(global_config, "mcp_instructions", None)
)
# Configure and register tools using shared module
config = MCPToolsConfig(
bank_id_resolver=get_current_bank_id,
@@ -161,8 +142,6 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
mcp_authenticated_resolver=get_current_mcp_authenticated, # Propagate MCP pre-auth flag
include_bank_id_param=multi_bank,
tools=base_tools,
retain_description=retain_description,
recall_description=recall_description,
)
register_mcp_tools(mcp, memory, config)
-263
View File
@@ -1,263 +0,0 @@
"""Open Knowledge Format (OKF) projection for knowledge pages.
Knowledge pages are a *read-only* OKF view over the existing mental models: each
mental model is projected into an OKF document — a markdown body with YAML
frontmatter (``type`` required; ``title``/``description``/``tags``/``timestamp``
optional) — and pages are linked into a constellation graph via shared tags.
See the Open Knowledge Format spec:
https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf
This module is intentionally pure: every function transforms the mental-model
dicts returned by ``MemoryEngine.list_mental_models`` / ``get_mental_model`` and
never touches the database. That keeps the OKF contract unit-testable without a
DB or LLM and lets the HTTP layer stay a thin wrapper.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
# OKF requires exactly one frontmatter field — ``type``. We default to this when
# a page does not declare one via a ``type:<x>`` tag.
DEFAULT_PAGE_TYPE = "knowledge-page"
# A page declares its OKF ``type`` through a tag of the form ``type:runbook``.
# This keeps the projection schema-free (no new mental_models column): the type
# is lifted from the existing tags array.
TYPE_TAG_PREFIX = "type:"
INDEX_FILENAME = "index.md"
# Deterministic, colour-blind-friendly palette. Type → colour is stable across
# requests so the constellation keeps the same colours between reloads.
_PALETTE = (
"#0074d9", # blue
"#2ecc40", # green
"#b10dc9", # purple
"#ff851b", # orange
"#39cccc", # teal
"#f012be", # magenta
"#3d9970", # olive
"#ff4136", # red
)
_EDGE_COLOR = "#9aa5b1"
@dataclass(frozen=True)
class PageType:
"""A page's OKF ``type`` and the tags that remain after the type tag is split off."""
type: str
display_tags: list[str]
@dataclass(frozen=True)
class KnowledgeGraph:
"""Cytoscape-style node/edge graph of knowledge pages linked by shared tags."""
nodes: list[dict[str, Any]] = field(default_factory=list)
edges: list[dict[str, Any]] = field(default_factory=list)
def _color_for(key: str) -> str:
"""Stable colour for a string key (FNV-ish hash into the fixed palette)."""
h = 0
for ch in key:
h = (h * 31 + ord(ch)) & 0xFFFFFFFF
return _PALETTE[h % len(_PALETTE)]
def _scalar(value: Any) -> str:
"""Emit a YAML-safe double-quoted scalar.
We always double-quote so arbitrary page names / source queries can't be
misread as YAML special forms (``true``, ``2026-01-01``, ``- x``, etc.).
"""
text = str(value)
escaped = text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "")
return f'"{escaped}"'
def page_type(tags: list[str] | None) -> PageType:
"""Split an OKF ``type`` out of the tag list.
The first ``type:<x>`` tag wins; all ``type:`` tags are removed from the
returned ``display_tags`` so they don't pollute the constellation's
shared-tag edges. Falls back to :data:`DEFAULT_PAGE_TYPE`.
"""
resolved = DEFAULT_PAGE_TYPE
display: list[str] = []
for tag in tags or []:
if tag.startswith(TYPE_TAG_PREFIX):
suffix = tag[len(TYPE_TAG_PREFIX) :].strip()
if suffix and resolved == DEFAULT_PAGE_TYPE:
resolved = suffix
continue
display.append(tag)
return PageType(type=resolved, display_tags=display)
def _timestamp(mm: dict[str, Any]) -> str | None:
return mm.get("last_refreshed_at") or mm.get("created_at")
def frontmatter(mm: dict[str, Any]) -> dict[str, Any]:
"""Build the ordered OKF frontmatter mapping for a mental model.
``None``/empty values are dropped by :func:`render_frontmatter`.
"""
pt = page_type(mm.get("tags"))
return {
"id": mm.get("id"),
"type": pt.type,
"title": mm.get("name"),
"description": mm.get("source_query"),
"tags": pt.display_tags,
"timestamp": _timestamp(mm),
}
def render_frontmatter(fm: dict[str, Any]) -> str:
"""Render a frontmatter mapping into a ``---`` fenced YAML block."""
lines = ["---"]
for key, value in fm.items():
if value is None:
continue
if isinstance(value, list):
if not value:
continue
lines.append(f"{key}:")
lines.extend(f" - {_scalar(item)}" for item in value)
else:
lines.append(f"{key}: {_scalar(value)}")
lines.append("---")
return "\n".join(lines)
def render_document(mm: dict[str, Any]) -> str:
"""Render a full OKF document: frontmatter block + markdown body."""
body = (mm.get("content") or "").strip()
return f"{render_frontmatter(frontmatter(mm))}\n\n{body}\n" if body else f"{render_frontmatter(frontmatter(mm))}\n"
def page_filename(page_id: str) -> str:
"""OKF bundle filename for a page id."""
return f"{page_id}.md"
def log_filename(page_id: str) -> str:
"""OKF reserved per-page history filename."""
return f"{page_id}.log.md"
def render_index(nodes: list[dict[str, Any]]) -> str:
"""Render the reserved ``index.md`` — nested OKF navigation over the tree.
``nodes`` is the flat folder/page list (each with ``id``, ``kind``, ``name``,
``parent_id``); folders nest their children, pages link to their ``.md``.
"""
fm = render_frontmatter({"type": "index", "title": "Knowledge base"})
lines = [fm, "", "# Knowledge base", ""]
children: dict[Any, list[dict[str, Any]]] = {}
for node in nodes:
children.setdefault(node.get("parent_id"), []).append(node)
def walk(parent: Any, depth: int) -> None:
ordered = sorted(children.get(parent, []), key=lambda n: (n.get("sort_order", 0), n.get("name") or ""))
for node in ordered:
indent = " " * depth
if node.get("kind") == "folder":
lines.append(f"{indent}- **{node['name']}/**")
walk(node["id"], depth + 1)
else:
description = node.get("source_query") or node.get("description")
link = f"{indent}- [{node['name']}](./{page_filename(node['id'])})"
lines.append(f"{link}{description}" if description else link)
walk(None, 0)
if len(lines) == 4:
lines.append("_No knowledge pages yet._")
return "\n".join(lines) + "\n"
def render_log(mm: dict[str, Any], history: list[dict[str, Any]]) -> str:
"""Render the reserved per-page ``log.md`` from refresh history.
Each history entry is ``{previous_content, previous_reflect_response,
changed_at}`` (newest first), capturing the content *before* a refresh.
"""
name = mm.get("name") or mm.get("id")
fm = render_frontmatter({"type": "log", "title": f"{name} — history"})
lines = [fm, "", f"# {name} — history", ""]
if not history:
lines.append("_No refresh history._")
return "\n".join(lines) + "\n"
for entry in history:
changed_at = entry.get("changed_at") or "unknown"
previous = (entry.get("previous_content") or "").strip()
lines.append(f"## {changed_at}")
lines.append("")
lines.append(previous if previous else "_(empty)_")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def knowledge_graph(
pages: list[dict[str, Any]],
cluster_for: "Callable[[dict[str, Any]], str] | None" = None,
) -> KnowledgeGraph:
"""Derive the constellation graph: pages as nodes, shared tags as edges.
Two pages are linked when they share at least one (non-``type:``) tag; the
edge weight is the number of shared tags. Each node's cluster (``type`` field
+ colour) comes from ``cluster_for(page)`` — the knowledge base groups by
parent folder; the default groups by OKF ``type``.
"""
nodes: list[dict[str, Any]] = []
tag_sets: list[tuple[str, frozenset[str]]] = []
for mm in pages:
page_id = mm["id"]
pt = page_type(mm.get("tags"))
cluster = cluster_for(mm) if cluster_for else pt.type
tag_sets.append((page_id, frozenset(pt.display_tags)))
nodes.append(
{
"data": {
"id": page_id,
"label": mm.get("name") or page_id,
"type": cluster,
"tagCount": len(pt.display_tags),
"color": _color_for(cluster),
}
}
)
edges: list[dict[str, Any]] = []
for i in range(len(tag_sets)):
source_id, source_tags = tag_sets[i]
if not source_tags:
continue
for j in range(i + 1, len(tag_sets)):
target_id, target_tags = tag_sets[j]
shared = source_tags & target_tags
if not shared:
continue
edges.append(
{
"data": {
"id": f"{source_id}--{target_id}",
"source": source_id,
"target": target_id,
"sharedTags": sorted(shared),
"weight": len(shared),
"color": _EDGE_COLOR,
}
}
)
return KnowledgeGraph(nodes=nodes, edges=edges)
@@ -1,85 +0,0 @@
"""Cooperative cancellation for long-running engine operations.
Recall runs as a staged pipeline whose heavy stages — graph expansion and
cross-encoder reranking — execute in worker threads (``run_in_executor``) that
asyncio task cancellation cannot interrupt once they have started. Cancelling
the awaiting task only unblocks the ``await``; the thread keeps burning CPU to
completion. So rather than rely on task cancellation, callers thread a
``CancellationToken`` through ``RequestContext`` and the engine checks it at
stage boundaries (``raise_if_cancelled``), bailing out *before* dispatching the
next expensive stage.
This is cooperative by design: it cannot stop a computation already inside a
worker thread, but it does stop an abandoned recall from progressing into — or
past — that work, which is what starves the instance in issue #2122. The token
lives on ``RequestContext``, so any operation that receives one (recall today;
reflect/consolidation/MCP later) can adopt the same checkpoints, and any driver
(client disconnect today; a deadline tomorrow) can fire it.
"""
from __future__ import annotations
import asyncio
class OperationCancelledError(Exception):
"""Raised at a checkpoint when the operation has been cancelled.
Carries the ``reason`` set by whoever cancelled (e.g. "client disconnected")
so the HTTP layer can translate it into the appropriate status code instead
of a generic 500.
NOTE: this is a plain ``Exception`` on purpose, NOT ``BaseException``. The
recall/reflect pipelines have broad ``except Exception`` handlers that would
otherwise swallow it — those handlers re-raise ``OperationCancelledError``
explicitly (see ``_search_with_retries``) so cancellation propagates to the
HTTP layer. A ``BaseException`` would dodge those handlers but also slip past
legitimate ``isinstance(result, Exception)`` checks (e.g. the reflect agent's
``asyncio.gather(..., return_exceptions=True)`` tool-result handling), which
expect every non-tuple result to be an ``Exception``.
"""
def __init__(self, reason: str = "operation cancelled") -> None:
super().__init__(reason)
self.reason = reason
class CancellationToken:
"""A one-shot, cooperative cancellation signal.
Cheap to poll (``raise_if_cancelled``) at stage boundaries and awaitable
(``wait``) so a driver task can block until cancellation. Safe to share
across an engine call tree; polling is a no-op until something cancels, and
cancellation is idempotent (the first reason wins).
"""
__slots__ = ("_event", "_reason")
def __init__(self) -> None:
self._event = asyncio.Event()
self._reason = "operation cancelled"
def cancel(self, reason: str = "operation cancelled") -> None:
"""Signal cancellation. Idempotent; the first reason recorded wins."""
if not self._event.is_set():
self._reason = reason
self._event.set()
@property
def cancelled(self) -> bool:
"""Whether cancellation has been signalled."""
return self._event.is_set()
@property
def reason(self) -> str:
"""The reason recorded by the first ``cancel`` call."""
return self._reason
def raise_if_cancelled(self) -> None:
"""Raise ``OperationCancelledError`` if cancellation has been signalled."""
if self._event.is_set():
raise OperationCancelledError(self._reason)
async def wait(self) -> None:
"""Block until cancellation is signalled."""
await self._event.wait()
+17 -532
View File
@@ -141,35 +141,9 @@ ENV_LLM_TIMEOUT = "HINDSIGHT_API_LLM_TIMEOUT"
ENV_LLM_REASONING_EFFORT = "HINDSIGHT_API_LLM_REASONING_EFFORT"
ENV_LLM_GROQ_SERVICE_TIER = "HINDSIGHT_API_LLM_GROQ_SERVICE_TIER"
ENV_LLM_OPENAI_SERVICE_TIER = "HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER"
ENV_LLM_BEDROCK_SERVICE_TIER = "HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER"
ENV_LLM_GEMINI_SERVICE_TIER = "HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER"
ENV_LLM_EXTRA_BODY = "HINDSIGHT_API_LLM_EXTRA_BODY"
ENV_LLM_DEFAULT_HEADERS = "HINDSIGHT_API_LLM_DEFAULT_HEADERS"
ENV_LLM_STRICT_SCHEMA = "HINDSIGHT_API_LLM_STRICT_SCHEMA"
ENV_LLM_SEND_BANK_AS_USER = "HINDSIGHT_API_LLM_SEND_BANK_AS_USER"
# Per-operation sampling temperature. Each internal LLM call uses a temperature
# tuned for its task (deterministic extraction vs. creative reflection). These
# expose those as overridable knobs. Resolution per operation:
# per-operation env -> global env (ENV_LLM_TEMPERATURE) -> built-in default.
# A value of "none"/"default"/"" (or "off") omits the temperature parameter
# entirely, for models that reject explicit temperatures (e.g. Azure GPT-5.5,
# which only accepts the default value) -- see issue #2459.
ENV_LLM_TEMPERATURE = "HINDSIGHT_API_LLM_TEMPERATURE"
ENV_LLM_TEMPERATURE_VERIFICATION = "HINDSIGHT_API_LLM_TEMPERATURE_VERIFICATION"
ENV_LLM_TEMPERATURE_RETAIN = "HINDSIGHT_API_LLM_TEMPERATURE_RETAIN"
ENV_LLM_TEMPERATURE_REFLECT = "HINDSIGHT_API_LLM_TEMPERATURE_REFLECT"
ENV_LLM_TEMPERATURE_CONSOLIDATION = "HINDSIGHT_API_LLM_TEMPERATURE_CONSOLIDATION"
# Multi-LLM strategy. Extra LLMs are configured by index alongside the unindexed
# primary (e.g. HINDSIGHT_API_LLM_1_PROVIDER, HINDSIGHT_API_LLM_2_PROVIDER, ...),
# and HINDSIGHT_API_LLM_STRATEGY (JSON) selects how to route across them — see
# _parse_llm_members / _parse_llm_strategy below. Each operation can override the
# global chain with its own HINDSIGHT_API_<OP>_LLM_<n>_* members + _STRATEGY.
ENV_LLM_STRATEGY = "HINDSIGHT_API_LLM_STRATEGY"
ENV_RETAIN_LLM_STRATEGY = "HINDSIGHT_API_RETAIN_LLM_STRATEGY"
ENV_REFLECT_LLM_STRATEGY = "HINDSIGHT_API_REFLECT_LLM_STRATEGY"
ENV_CONSOLIDATION_LLM_STRATEGY = "HINDSIGHT_API_CONSOLIDATION_LLM_STRATEGY"
# LiteLLM Router chain — provider-specific config consumed by the "litellmrouter"
# provider. Each entry is a deployment; the Router tries them in declared order and
@@ -179,75 +153,14 @@ ENV_CONSOLIDATION_LLM_STRATEGY = "HINDSIGHT_API_CONSOLIDATION_LLM_STRATEGY"
# disambiguates from the embeddings/reranker LITELLM_* settings.
ENV_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG"
# Per-operation temperature defaults (preserve historical hardcoded values).
DEFAULT_LLM_TEMPERATURE_VERIFICATION = 0.0 # connection check
DEFAULT_LLM_TEMPERATURE_RETAIN = 0.1 # fact extraction
DEFAULT_LLM_TEMPERATURE_REFLECT = 0.9 # reflect "thinking"
DEFAULT_LLM_TEMPERATURE_CONSOLIDATION = 0.0 # mental-model delta / dedup
# Defaults for service tiers
DEFAULT_LLM_GROQ_SERVICE_TIER = "auto" # "on_demand", "flex", or "auto"
DEFAULT_LLM_OPENAI_SERVICE_TIER = None # None (default) or "flex" (50% cheaper)
DEFAULT_LLM_BEDROCK_SERVICE_TIER = None # None (default), "flex", "priority", or "reserved"
DEFAULT_LLM_GEMINI_SERVICE_TIER = None # None (default) or "flex" (50% cheaper best-effort tier)
DEFAULT_LLM_EXTRA_BODY = None # None = no extra body params; JSON dict merged into OpenAI extra_body
DEFAULT_LLM_DEFAULT_HEADERS = (
None # None = no extra headers; JSON dict passed as default_headers to provider SDK clients
)
def parse_gemini_service_tier(value: str | None) -> str | None:
"""Normalize and validate the Gemini service tier."""
tier = value or None
valid_tiers = (None, "flex")
if tier not in valid_tiers:
raise ValueError(
f"Invalid HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER: "
f"{tier!r}. Must be one of: {', '.join(t for t in valid_tiers if t is not None)}."
)
return tier
# Sentinel strings that, as a temperature value, mean "omit the temperature
# parameter entirely" rather than a numeric setting.
_TEMPERATURE_OMIT_VALUES = frozenset({"", "none", "default", "off", "unset"})
def _parse_temperature(raw: str) -> float | None:
"""Parse a raw temperature env value into a float, or None to omit it.
Returns None for the omit sentinels (so the temperature parameter is dropped
from the LLM call); otherwise parses a float and validates the 0.0-2.0 range.
"""
if raw.strip().lower() in _TEMPERATURE_OMIT_VALUES:
return None
try:
value = float(raw)
except ValueError as e:
raise ValueError(
f"Invalid LLM temperature {raw!r}: must be a number in [0.0, 2.0] "
f"or one of {sorted(_TEMPERATURE_OMIT_VALUES)} to omit it."
) from e
if not 0.0 <= value <= 2.0:
raise ValueError(f"Invalid LLM temperature {value}: must be in [0.0, 2.0].")
return value
def _resolve_operation_temperature(operation_env: str, default: float) -> float | None:
"""Resolve a per-operation temperature: per-op env -> global env -> default.
The omit sentinels resolve to None at any layer, so a single
``HINDSIGHT_API_LLM_TEMPERATURE=none`` drops temperature from every operation
that has no explicit per-operation override.
"""
raw = os.getenv(operation_env)
if raw is None:
raw = os.getenv(ENV_LLM_TEMPERATURE)
if raw is None:
return default
return _parse_temperature(raw)
# Per-operation LLM configuration (optional, falls back to global LLM config)
ENV_RETAIN_LLM_PROVIDER = "HINDSIGHT_API_RETAIN_LLM_PROVIDER"
ENV_RETAIN_LLM_API_KEY = "HINDSIGHT_API_RETAIN_LLM_API_KEY"
@@ -339,12 +252,6 @@ ENV_EMBEDDINGS_OPENROUTER_API_KEY = "HINDSIGHT_API_EMBEDDINGS_OPENROUTER_API_KEY
ENV_EMBEDDINGS_OPENROUTER_MODEL = "HINDSIGHT_API_EMBEDDINGS_OPENROUTER_MODEL"
ENV_RERANKER_OPENROUTER_API_KEY = "HINDSIGHT_API_RERANKER_OPENROUTER_API_KEY"
ENV_RERANKER_OPENROUTER_MODEL = "HINDSIGHT_API_RERANKER_OPENROUTER_MODEL"
ENV_RERANKER_OPENROUTER_BASE_URL = "HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL"
# Requesty configuration (OpenAI-compatible gateway; embeddings)
ENV_REQUESTY_API_KEY = "HINDSIGHT_API_REQUESTY_API_KEY"
ENV_EMBEDDINGS_REQUESTY_API_KEY = "HINDSIGHT_API_EMBEDDINGS_REQUESTY_API_KEY"
ENV_EMBEDDINGS_REQUESTY_MODEL = "HINDSIGHT_API_EMBEDDINGS_REQUESTY_MODEL"
# ZeroEntropy configuration (embeddings)
ENV_EMBEDDINGS_ZEROENTROPY_API_KEY = "HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_API_KEY"
@@ -443,10 +350,7 @@ ENV_ACCESS_LOG = "HINDSIGHT_API_ACCESS_LOG"
ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
ENV_MCP_ENABLED_TOOLS = "HINDSIGHT_API_MCP_ENABLED_TOOLS"
ENV_MCP_STATELESS = "HINDSIGHT_API_MCP_STATELESS"
ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
ENV_ENABLE_BANK_CONFIG_API = "HINDSIGHT_API_ENABLE_BANK_CONFIG_API"
ENV_ENABLE_BANK_LLM_HEALTH = "HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH"
ENV_ENABLE_DRY_RUN_EXTRACT = "HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT"
ENV_DEFAULT_BANK_TEMPLATE = "HINDSIGHT_API_DEFAULT_BANK_TEMPLATE"
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
@@ -465,7 +369,6 @@ ENV_OTEL_EXPORTER_OTLP_HEADERS = "HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS"
ENV_OTEL_SERVICE_NAME = "HINDSIGHT_API_OTEL_SERVICE_NAME"
ENV_OTEL_DEPLOYMENT_ENVIRONMENT = "HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT"
ENV_METRICS_INCLUDE_BANK_ID = "HINDSIGHT_API_METRICS_INCLUDE_BANK_ID"
ENV_METRICS_BACKLOG_ENABLED = "HINDSIGHT_API_METRICS_BACKLOG_ENABLED"
# Vertex AI configuration
ENV_LLM_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"
@@ -488,7 +391,6 @@ ENV_LLM_PROMPT_CACHE_ENABLED = "HINDSIGHT_API_LLM_PROMPT_CACHE_ENABLED"
# Retain settings
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
ENV_RETAIN_STRUCTURED_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE"
ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
ENV_RETAIN_MISSION = "HINDSIGHT_API_RETAIN_MISSION"
@@ -515,11 +417,6 @@ ENV_FILE_STORAGE_AZURE_ACCOUNT_NAME = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_
ENV_FILE_STORAGE_AZURE_ACCOUNT_KEY = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_KEY"
ENV_FILE_PARSER = "HINDSIGHT_API_FILE_PARSER"
ENV_FILE_PARSER_ALLOWLIST = "HINDSIGHT_API_FILE_PARSER_ALLOWLIST"
ENV_FILE_PARSER_MARKITDOWN_OCR_ENABLED = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED"
ENV_FILE_PARSER_MARKITDOWN_OCR_API_KEY = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY"
ENV_FILE_PARSER_MARKITDOWN_OCR_BASE_URL = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL"
ENV_FILE_PARSER_MARKITDOWN_OCR_MODEL = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL"
ENV_FILE_PARSER_MARKITDOWN_OCR_PROMPT = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_PROMPT"
ENV_FILE_PARSER_IRIS_TOKEN = "HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN"
ENV_FILE_PARSER_IRIS_ORG_ID = "HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID"
ENV_FILE_PARSER_LLAMA_PARSE_API_KEY = "HINDSIGHT_API_FILE_PARSER_LLAMA_PARSE_API_KEY"
@@ -527,7 +424,6 @@ ENV_FILE_CONVERSION_MAX_BATCH_SIZE_MB = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH
ENV_FILE_CONVERSION_MAX_BATCH_SIZE = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE"
ENV_ENABLE_FILE_UPLOAD_API = "HINDSIGHT_API_ENABLE_FILE_UPLOAD_API"
ENV_FILE_DELETE_AFTER_RETAIN = "HINDSIGHT_API_FILE_DELETE_AFTER_RETAIN"
ENV_STORE_DOCUMENT_TEXT = "HINDSIGHT_API_STORE_DOCUMENT_TEXT"
# Document transfer (export/import documents between banks without re-running the LLM)
ENV_ENABLE_DOCUMENT_EXPORT_API = "HINDSIGHT_API_ENABLE_DOCUMENT_EXPORT_API"
@@ -551,7 +447,6 @@ ENV_CONSOLIDATION_RECALL_BUDGET = "HINDSIGHT_API_CONSOLIDATION_RECALL_BUDGET"
ENV_CONSOLIDATION_MAX_ATTEMPTS = "HINDSIGHT_API_CONSOLIDATION_MAX_ATTEMPTS"
ENV_OBSERVATIONS_MISSION = "HINDSIGHT_API_OBSERVATIONS_MISSION"
ENV_MAX_OBSERVATIONS_PER_SCOPE = "HINDSIGHT_API_MAX_OBSERVATIONS_PER_SCOPE"
ENV_OBSERVATION_SCOPE_LIMITS = "HINDSIGHT_API_OBSERVATION_SCOPE_LIMITS"
ENV_ENABLE_OBSERVATION_HISTORY = "HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY"
ENV_OBSERVATION_HISTORY_MAX_ENTRIES = "HINDSIGHT_API_OBSERVATION_HISTORY_MAX_ENTRIES"
ENV_ENABLE_MENTAL_MODEL_HISTORY = "HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY"
@@ -573,10 +468,10 @@ ENV_LLAMACPP_EXTRA_ARGS = "HINDSIGHT_API_LLAMACPP_EXTRA_ARGS"
# Optimization flags
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER"
# Database migrations
ENV_RUN_MIGRATIONS_ON_STARTUP = "HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP"
ENV_MIGRATION_CONCURRENCY = "HINDSIGHT_API_MIGRATION_CONCURRENCY"
# Database connection pool
ENV_DB_POOL_MIN_SIZE = "HINDSIGHT_API_DB_POOL_MIN_SIZE"
@@ -648,14 +543,6 @@ ENV_RECALL_MAX_CANDIDATES_PER_SOURCE = "HINDSIGHT_API_RECALL_MAX_CANDIDATES_PER_
# Empty disables the feature.
ENV_RECALL_STRATEGY_BOOSTS = "HINDSIGHT_API_RECALL_STRATEGY_BOOSTS"
# Recency decay used by recall reranking (engine/search/reranking.py). The decay
# function maps a memory's age onto a freshness signal that nudges its final
# ranking via a small multiplicative boost. "linear" (default) preserves the
# historical behaviour; "exponential" decays by half-life; "none" disables it.
ENV_RECENCY_DECAY_FUNCTION = "HINDSIGHT_API_RECENCY_DECAY_FUNCTION"
ENV_RECENCY_DECAY_LINEAR_WINDOW_DAYS = "HINDSIGHT_API_RECENCY_DECAY_LINEAR_WINDOW_DAYS"
ENV_RECENCY_DECAY_HALFLIFE_DAYS = "HINDSIGHT_API_RECENCY_DECAY_HALFLIFE_DAYS"
# Audit log settings
ENV_AUDIT_LOG_ENABLED = "HINDSIGHT_API_AUDIT_LOG_ENABLED"
ENV_AUDIT_LOG_ACTIONS = "HINDSIGHT_API_AUDIT_LOG_ACTIONS"
@@ -669,7 +556,6 @@ ENV_LLM_TRACE_MAX_CHARS = "HINDSIGHT_API_LLM_TRACE_MAX_CHARS"
# Background maintenance settings
ENV_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS = "HINDSIGHT_API_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS"
ENV_MENTAL_MODEL_REFRESH_TICK_SECONDS = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_TICK_SECONDS"
# Disposition settings
ENV_DISPOSITION_SKEPTICISM = "HINDSIGHT_API_DISPOSITION_SKEPTICISM"
@@ -692,7 +578,6 @@ PROVIDER_DEFAULT_MODELS = {
"deepseek": "deepseek-v4-flash",
"zai": "glm-4.5-flash",
"opencode-go": "deepseek-v4-flash",
"atlas": "deepseek-ai/deepseek-v4-pro",
"ollama": "gemma3:12b",
"ollama-cloud": "gemma3:12b",
"llamacpp": "gemma-4-e2b-it",
@@ -706,9 +591,7 @@ PROVIDER_DEFAULT_MODELS = {
"bedrock": "us.amazon.nova-2-lite-v1:0",
"volcano": "doubao-pro-32k",
"openrouter": "qwen/qwen3.5-9b",
"requesty": "openai/gpt-4o-mini",
"fireworks": "accounts/fireworks/models/llama-v3p1-8b-instruct",
"nous": "deepseek/deepseek-v4-flash",
}
DEFAULT_LLM_MODEL = "gpt-4o-mini" # Fallback if provider not in table
# Built-in llama.cpp defaults
@@ -732,7 +615,6 @@ DEFAULT_LLM_INITIAL_BACKOFF = 1.0 # Initial backoff in seconds for retry expone
DEFAULT_LLM_MAX_BACKOFF = 60.0 # Max backoff cap in seconds for retry exponential backoff
DEFAULT_LLM_TIMEOUT = 120.0 # seconds
DEFAULT_LLM_REASONING_EFFORT = "low"
DEFAULT_LLM_SEND_BANK_AS_USER = False # Opt-in: tag provider calls with user=<bank_id>
# Vertex AI defaults
DEFAULT_LLM_VERTEXAI_PROJECT_ID = None # Required for Vertex AI
@@ -797,14 +679,6 @@ DEFAULT_RECALL_MAX_CANDIDATES_PER_SOURCE = 0
# "graph:high,semantic:low"). Empty disables the feature. See
# ENV_RECALL_STRATEGY_BOOSTS for the full rationale.
DEFAULT_RECALL_STRATEGY_BOOSTS = ""
# Recency decay shape used by recall reranking. "linear" reproduces the
# historical straight-line decay; defaults below keep behaviour unchanged.
RECENCY_DECAY_FUNCTIONS = ("linear", "exponential", "none")
DEFAULT_RECENCY_DECAY_FUNCTION = "linear"
# Linear: days over which freshness decays from 1.0 to its 0.1 floor.
DEFAULT_RECENCY_DECAY_LINEAR_WINDOW_DAYS = 365.0
# Exponential: age (days) at which the recency signal is neutral (0.5).
DEFAULT_RECENCY_DECAY_HALFLIFE_DAYS = 90.0
# Retrieval arms that can be boosted; mirrors fusion.py source_names.
RECALL_STRATEGY_NAMES = ("semantic", "bm25", "graph", "temporal")
# User-facing priority levels. Kept in sync with recall_boost.BOOST_LEVELS by a
@@ -861,10 +735,6 @@ DEFAULT_RERANKER_COHERE_MODEL = "rerank-english-v3.0"
# OpenRouter defaults
DEFAULT_EMBEDDINGS_OPENROUTER_MODEL = "perplexity/pplx-embed-v1-0.6b"
DEFAULT_RERANKER_OPENROUTER_MODEL = "cohere/rerank-v3.5"
DEFAULT_RERANKER_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1/rerank"
# Requesty defaults
DEFAULT_EMBEDDINGS_REQUESTY_MODEL = "openai/text-embedding-3-small"
# ZeroEntropy defaults
DEFAULT_EMBEDDINGS_ZEROENTROPY_MODEL = "zembed-1"
@@ -921,15 +791,7 @@ DEFAULT_ACCESS_LOG = False
DEFAULT_MCP_ENABLED = True
DEFAULT_MCP_ENABLED_TOOLS: list[str] | None = None # None = all tools enabled
DEFAULT_MCP_STATELESS = False # False = stateful (supports SSE/GET); True = stateless (POST-only)
DEFAULT_MCP_INSTRUCTIONS = None
DEFAULT_ENABLE_BANK_CONFIG_API = True
# Dry-run extraction is a preview tool that makes a real LLM call but stores nothing. Enabled by
# default; set HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=false to remove the endpoint (e.g. to cap
# provider cost/abuse on untrusted deployments).
DEFAULT_ENABLE_DRY_RUN_EXTRACT = True
# The per-bank LLM connectivity probe makes a real provider call, so it's OFF by
# default (cost/abuse concerns) and must be explicitly enabled to expose the endpoint.
DEFAULT_ENABLE_BANK_LLM_HEALTH = False
DEFAULT_DEFAULT_BANK_TEMPLATE: dict | None = None # BankTemplateManifest dict applied to newly-created banks
DEFAULT_GRAPH_RETRIEVER = "link_expansion"
DEFAULT_RECALL_MAX_CONCURRENT = 32 # Max concurrent recall operations per worker
@@ -965,15 +827,10 @@ DEFAULT_RETAIN_BATCH_POLL_INTERVAL_SECONDS = 60 # Batch API polling interval in
DEFAULT_FILE_STORAGE_TYPE = "native" # PostgreSQL BYTEA storage
DEFAULT_FILE_PARSER = "markitdown" # Default parser fallback chain (comma-separated, e.g. "iris,markitdown")
DEFAULT_FILE_PARSER_ALLOWLIST = None # Allowlist of parsers clients may request (None = all registered parsers)
DEFAULT_FILE_PARSER_MARKITDOWN_OCR_ENABLED = False
DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT = """You are a precise OCR transcription engine.
Transcribe only the visible text in the image. Do not describe the image, summarize it, translate it, infer missing content, or add commentary. Preserve the original language, wording, numbers, punctuation, capitalization, and reading order. Reconstruct headings, lists, key-value fields, stamps, and tables as clean Markdown when the layout is clear. If text is unreadable or uncertain, write [unclear] for that span. Return only the extracted Markdown."""
DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE_MB = 100 # Max total batch size in MB (all files combined)
DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE = 10 # Max files per batch upload
DEFAULT_ENABLE_FILE_UPLOAD_API = True # Enable file upload endpoint
DEFAULT_FILE_DELETE_AFTER_RETAIN = True # Delete file bytes after retain (saves storage)
DEFAULT_STORE_DOCUMENT_TEXT = True # Persist raw source text in documents.original_text / chunks.chunk_text
# Document transfer defaults (export/import enabled by default; gated independently)
DEFAULT_ENABLE_DOCUMENT_EXPORT_API = True
@@ -1023,16 +880,9 @@ DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = (
)
DEFAULT_OBSERVATIONS_MISSION = None # Declarative spec of what observations are for this bank
DEFAULT_MAX_OBSERVATIONS_PER_SCOPE = -1 # Max observations per tag scope (-1 = unlimited)
# Per-scope overrides of the cap above: list of {"scope": [tag-globs], "limit": int}.
# First rule whose pattern exact-covers a scope's tags wins; else the default above.
DEFAULT_OBSERVATION_SCOPE_LIMITS: list | None = None
# Database migrations
DEFAULT_RUN_MIGRATIONS_ON_STARTUP = True
# Number of tenant schemas to migrate concurrently. Each schema runs in its own
# process (Alembic's command.upgrade() is not thread-safe); within a schema the
# work is always sequential. 1 = fully sequential (the safe default).
DEFAULT_MIGRATION_CONCURRENCY = 1
# Database connection pool
DEFAULT_DB_POOL_MIN_SIZE = 5
@@ -1087,7 +937,6 @@ DEFAULT_OTEL_TRACES_ENABLED = False # Disabled by default for backward compatib
DEFAULT_OTEL_SERVICE_NAME = "hindsight-api"
DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT = "development"
DEFAULT_METRICS_INCLUDE_BANK_ID = False # Disabled by default to avoid high-cardinality OTel metric growth
DEFAULT_METRICS_BACKLOG_ENABLED = False # Disabled by default: runs periodic per-schema COUNT queries
# Audit log defaults
DEFAULT_AUDIT_LOG_ENABLED = False # Disabled by default
@@ -1106,11 +955,6 @@ DEFAULT_LLM_TRACE_MAX_CHARS = 50000 # Truncate stored input/output beyond this
# 0 disables the reconcile sweep.
DEFAULT_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS = 300
# How often the maintenance loop checks for cron-scheduled mental models that are
# due for a refresh. This is the *check* cadence; the actual schedule is the
# per-model cron expression in the mental model's trigger. 0 disables the sweep.
DEFAULT_MENTAL_MODEL_REFRESH_TICK_SECONDS = 60
# Default MCP tool descriptions (can be customized via env vars)
DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory.
@@ -1216,63 +1060,6 @@ def _parse_optional_positive_int(name: str, raw: str | None) -> int | None:
return _parse_positive_int(name, raw, 1)
def _validate_retain_chunking_int(name: str, value: Any) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{name} must be an integer, got {value!r}")
if value < 1:
raise ValueError(f"{name} must be >= 1, got {value}")
return value
def validate_retain_chunking_config(
retain_chunk_size: Any,
retain_structured_chunk_size: Any,
*,
retain_chunk_size_name: str = "retain_chunk_size",
retain_structured_chunk_size_name: str = "retain_structured_chunk_size",
) -> None:
"""Validate retain chunking size fields.
Defaults emit field-style names ("retain_chunk_size") so API/PATCH callers
don't have to override them. The startup validator (HindsightConfig.validate)
overrides to env-style names ("HINDSIGHT_API_RETAIN_CHUNK_SIZE") for env
misconfig errors.
"""
_validate_retain_chunking_int(retain_chunk_size_name, retain_chunk_size)
if retain_structured_chunk_size is None:
return
_validate_retain_chunking_int(
retain_structured_chunk_size_name,
retain_structured_chunk_size,
)
def validate_retain_completion_token_budget(
*,
llm_provider: str,
retain_max_completion_tokens: int,
retain_chunk_size: int,
retain_llm_model: str | None = None,
llm_model: str | None = None,
retain_llm_provider: str | None = None,
retain_max_completion_tokens_name: str = "retain_max_completion_tokens",
retain_chunk_size_name: str = "retain_chunk_size",
) -> None:
"""Validate that retain LLM output capacity exceeds the configured chunk size."""
if llm_provider == "none" or retain_max_completion_tokens > retain_chunk_size:
return
raise ValueError(
f"Invalid configuration: {retain_max_completion_tokens_name} "
f"({retain_max_completion_tokens}) must be greater than "
f"{retain_chunk_size_name} ({retain_chunk_size}). "
f"\n\nYou have two options to fix this:"
f"\n 1. Increase {retain_max_completion_tokens_name} to a value > {retain_chunk_size}"
f"\n 2. Use a model that supports at least {retain_max_completion_tokens} output tokens"
f"\n (current model: {retain_llm_model or llm_model}, "
f"provider: {retain_llm_provider or llm_provider})"
)
def _parse_optional_choice(name: str, raw: str | None, allowed: frozenset[str]) -> str | None:
"""Parse an optional string env var constrained to a small allowlist."""
if raw is None or raw == "":
@@ -1308,18 +1095,6 @@ def _validate_recall_budget_function(function: str) -> str:
return function_lower
def _validate_recency_decay_function(function: str) -> str:
"""Validate and normalize the recency decay function."""
function_lower = function.lower()
if function_lower not in RECENCY_DECAY_FUNCTIONS:
logger.warning(
f"Invalid recency decay function '{function}', must be one of {RECENCY_DECAY_FUNCTIONS}. "
f"Defaulting to '{DEFAULT_RECENCY_DECAY_FUNCTION}'."
)
return DEFAULT_RECENCY_DECAY_FUNCTION
return function_lower
def _parse_bank_priority(raw: str) -> dict[str, int]:
"""Parse ``bank-pattern:priority,...`` into ``{pattern: priority}``.
@@ -1375,132 +1150,6 @@ def _parse_llm_router_config(env_var: str) -> dict | None:
raise ValueError(f"Invalid {env_var}: invalid JSON: {e}") from e
@dataclass
class LLMMemberConfig:
"""One extra LLM in a multi-LLM chain, configured via indexed env vars.
Mirrors the subset of LLM settings an indexed member supports
(``HINDSIGHT_API_<OP>LLM_<n>_*``). The unindexed config remains the primary
member (index 0); these describe members 1..N.
"""
provider: str
api_key: str | None
model: str
base_url: str | None
reasoning_effort: str | None
extra_body: dict | None
default_headers: dict | None
bedrock_service_tier: str | None
gemini_service_tier: str | None
vertexai_project_id: str | None = None
vertexai_region: str | None = None
vertexai_service_account_key: str | None = None
litellmrouter_config: dict | None = None
# Valid multi-LLM strategy modes.
LLM_STRATEGY_FAILOVER = "failover"
LLM_STRATEGY_ROUND_ROBIN = "round-robin"
_VALID_LLM_STRATEGY_MODES = (LLM_STRATEGY_FAILOVER, LLM_STRATEGY_ROUND_ROBIN)
@dataclass
class LLMStrategyConfig:
"""How to route a request across the members of a multi-LLM chain.
``mode`` is "failover" (try members in order) or "round-robin" (rotate the
starting member per request, then fall through the rest on error). ``weights``
is round-robin only: positive integers, one per member (primary first), giving
an unbalanced rotation; ``None`` means uniform.
"""
mode: str
weights: list[int] | None = None
def _parse_llm_strategy(raw: str | None) -> LLMStrategyConfig | None:
"""Parse a multi-LLM strategy from a JSON env var.
Returns ``None`` when unset. The value must be a JSON object with a ``mode``
of "failover" or "round-robin"; ``weights`` (round-robin only) must be a list
of positive ints. Raises ``ValueError`` on any malformed input so
misconfiguration fails fast at startup rather than silently degrading.
"""
text = (raw or "").strip()
if not text:
return None
try:
parsed = json.loads(text)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid {ENV_LLM_STRATEGY}: invalid JSON: {e}") from e
if not isinstance(parsed, dict):
raise ValueError(f"Invalid LLM strategy: expected a JSON object, got {type(parsed).__name__}")
mode = parsed.get("mode")
if mode not in _VALID_LLM_STRATEGY_MODES:
raise ValueError(f"Invalid LLM strategy mode {mode!r}. Must be one of: {', '.join(_VALID_LLM_STRATEGY_MODES)}.")
weights = parsed.get("weights")
if weights is not None:
if mode != LLM_STRATEGY_ROUND_ROBIN:
raise ValueError(f"LLM strategy 'weights' is only valid with mode '{LLM_STRATEGY_ROUND_ROBIN}'.")
if not isinstance(weights, list) or not weights or not all(isinstance(w, int) and w > 0 for w in weights):
raise ValueError("LLM strategy 'weights' must be a non-empty list of positive integers.")
return LLMStrategyConfig(mode=mode, weights=weights)
def _parse_llm_members(prefix: str) -> list[LLMMemberConfig]:
"""Parse indexed extra-LLM members for an operation env prefix.
``prefix`` is the operation segment in the env name: ``""`` (global),
``"RETAIN_"``, ``"REFLECT_"`` or ``"CONSOLIDATION_"``. Members are read from
``HINDSIGHT_API_{prefix}LLM_{n}_PROVIDER`` for n = 1, 2, ... and scanning
stops at the first index whose ``_PROVIDER`` is unset (so indices must be
contiguous from 1). ``MODEL`` defaults to the provider's default model.
"""
from .engine.llm_wrapper import requires_api_key
members: list[LLMMemberConfig] = []
index = 1
while True:
base = f"HINDSIGHT_API_{prefix}LLM_{index}_"
provider = os.getenv(base + "PROVIDER")
if not provider:
break
api_key = os.getenv(base + "API_KEY") or None
if not api_key and requires_api_key(provider):
raise ValueError(
f"{base}API_KEY is required for provider '{provider}' (member {index} of the multi-LLM chain)."
)
gemini_service_tier = os.getenv(base + "GEMINI_SERVICE_TIER")
members.append(
LLMMemberConfig(
provider=provider,
api_key=api_key,
model=os.getenv(base + "MODEL") or _get_default_model_for_provider(provider),
base_url=os.getenv(base + "BASE_URL") or None,
reasoning_effort=os.getenv(base + "REASONING_EFFORT") or None,
extra_body=json.loads(os.getenv(base + "EXTRA_BODY", "null")),
default_headers=json.loads(os.getenv(base + "DEFAULT_HEADERS", "null")),
bedrock_service_tier=os.getenv(base + "BEDROCK_SERVICE_TIER") or None,
gemini_service_tier=(
parse_gemini_service_tier(gemini_service_tier) if provider.lower() == "gemini" else None
),
vertexai_project_id=os.getenv(base + "VERTEXAI_PROJECT_ID") or None,
vertexai_region=os.getenv(base + "VERTEXAI_REGION") or None,
vertexai_service_account_key=os.getenv(base + "VERTEXAI_SERVICE_ACCOUNT_KEY") or None,
litellmrouter_config=_parse_llm_router_config(base + "LITELLMROUTER_CONFIG"),
)
)
index += 1
return members
def _parse_default_bank_template(raw: str | None) -> dict | None:
"""
Parse HINDSIGHT_API_DEFAULT_BANK_TEMPLATE as JSON.
@@ -1563,8 +1212,6 @@ class HindsightConfig:
llm_reasoning_effort: str
llm_groq_service_tier: str # Groq: "on_demand", "flex", or "auto"
llm_openai_service_tier: str | None # OpenAI: None (default) or "flex" (50% cheaper)
llm_bedrock_service_tier: str | None # Bedrock: None (default), "flex", "priority", or "reserved"
llm_gemini_service_tier: str | None # Gemini: None (default) or "flex" (50% cheaper)
llm_extra_body: (
dict | None
) # Extra body params merged into OpenAI-compatible API calls (e.g. {"chat_template_kwargs": {"enable_thinking": true}})
@@ -1572,19 +1219,6 @@ class HindsightConfig:
dict | None
) # Custom headers passed as default_headers to provider SDK clients (e.g. {"X-Component-Id": "hindsight"} for proxies / request tracing)
llm_strict_schema: bool # Grammar-enforce structured output via the provider's strongest schema mode (see DEFAULT_LLM_STRICT_SCHEMA)
# Tags outbound OpenAI-compatible LLM + embedding calls with `user=<bank_id>` for
# per-bank cost attribution. Downstream cost gateways (OpenRouter usage accounting,
# LiteLLM, Helicone) key attribution on the OpenAI `user` field. Opt-in; never
# overrides a `user` the caller already set.
llm_send_bank_as_user: bool
# Per-operation sampling temperature. None means the temperature parameter is
# omitted from the call (for models that reject explicit temperatures). See
# ENV_LLM_TEMPERATURE and _resolve_operation_temperature.
llm_temperature_verification: float | None
llm_temperature_retain: float | None
llm_temperature_reflect: float | None
llm_temperature_consolidation: float | None
# LiteLLM Router chain (provider-specific; consumed by the "litellmrouter" provider).
# List of deployment dicts evaluated in order with fallback on transient errors.
@@ -1675,8 +1309,6 @@ class HindsightConfig:
embeddings_cohere_output_dimensions: int | None
embeddings_openrouter_api_key: str | None
embeddings_openrouter_model: str
embeddings_requesty_api_key: str | None
embeddings_requesty_model: str
embeddings_litellm_api_base: str
embeddings_litellm_api_key: str | None
embeddings_litellm_model: str
@@ -1712,16 +1344,12 @@ class HindsightConfig:
bm25_min_score: float
recall_max_candidates_per_source: int
recall_strategy_boosts: dict[str, str]
recency_decay_function: str
recency_decay_linear_window_days: float
recency_decay_halflife_days: float
reranker_cohere_api_key: str | None
reranker_cohere_model: str
reranker_cohere_base_url: str | None
reranker_cohere_timeout: float
reranker_openrouter_api_key: str | None
reranker_openrouter_model: str
reranker_openrouter_base_url: str
reranker_openrouter_timeout: float
reranker_litellm_api_base: str
reranker_litellm_api_key: str | None
@@ -1758,10 +1386,7 @@ class HindsightConfig:
mcp_enabled: bool
mcp_enabled_tools: list[str] | None # None = all tools; explicit list = allowlist
mcp_stateless: bool # True = stateless HTTP (POST-only); False = stateful (supports GET/SSE)
mcp_instructions: str | None # Additional instructions appended to retain/recall MCP tool descriptions
enable_bank_config_api: bool
enable_bank_llm_health: bool
enable_dry_run_extract: bool
# Default bank template (static, server-level only). When set, the manifest is applied
# to every newly-created bank, overriding the env/config defaults for any fields it sets.
default_bank_template: dict | None
@@ -1780,7 +1405,6 @@ class HindsightConfig:
# Retain settings
retain_max_completion_tokens: int
retain_chunk_size: int
retain_structured_chunk_size: int | None
retain_extract_causal_links: bool
retain_extraction_mode: str
retain_mission: str | None
@@ -1815,7 +1439,6 @@ class HindsightConfig:
file_conversion_max_batch_size: int # Max files per request
enable_file_upload_api: bool
file_delete_after_retain: bool
store_document_text: bool # When False, store NULL original_text / empty chunk_text
enable_document_export_api: bool
enable_document_import_api: bool
@@ -1839,10 +1462,6 @@ class HindsightConfig:
consolidation_max_attempts: int
observations_mission: str | None
max_observations_per_scope: int
# Per-scope observation caps overriding max_observations_per_scope.
# Raw JSON shape: [{"scope": ["run_*", "shared"], "limit": 1}, ...]
# (validated/applied in engine.consolidation.consolidator._effective_scope_limit)
observation_scope_limits: list | None
# Entity labels (controlled vocabulary of key:value classification labels extracted at retain time)
# List of label group dicts: [{key, description, type, optional, values: [{value, description}]}]
@@ -1851,10 +1470,6 @@ class HindsightConfig:
# When False: only label entities are extracted (or no entities at all if no labels configured)
entities_allow_free_form: bool
# Memory Defense policy (dict matching DefensePolicy schema — validated on write)
# None = Memory Defense disabled / not configured for this bank
memory_defense: dict | None
# Reflect agent settings
reflect_mission: str | None
reflect_source_facts_max_tokens: int
@@ -1885,10 +1500,10 @@ class HindsightConfig:
# Optimization flags
skip_llm_verification: bool
lazy_reranker: bool
# Database migrations
run_migrations_on_startup: bool
migration_concurrency: int
# Database connection pool
db_pool_min_size: int
@@ -1922,7 +1537,6 @@ class HindsightConfig:
otel_service_name: str
otel_deployment_environment: str
metrics_include_bank_id: bool
metrics_backlog_enabled: bool
# Audit log configuration (static - server-level only)
audit_log_enabled: bool # Master switch for audit logging
@@ -1939,9 +1553,6 @@ class HindsightConfig:
# Interval for the periodic sweep that re-schedules consolidation for banks with
# eligible-but-unscheduled facts. 0 = disabled.
consolidation_reconcile_interval_seconds: int
# How often the maintenance loop checks for cron-scheduled mental models due for
# refresh (the per-model schedule lives in the mental model trigger). 0 = disabled.
mental_model_refresh_tick_seconds: int
# Webhook configuration (static - server-level only, not per-bank)
webhook_url: str | None # Global webhook URL (None = disabled)
@@ -1960,25 +1571,6 @@ class HindsightConfig:
embeddings_zeroentropy_encoding_format: str = DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT
embeddings_zeroentropy_batch_size: int = DEFAULT_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE
embeddings_zeroentropy_latency: str | None = DEFAULT_EMBEDDINGS_ZEROENTROPY_LATENCY
file_parser_markitdown_ocr_enabled: bool = DEFAULT_FILE_PARSER_MARKITDOWN_OCR_ENABLED
file_parser_markitdown_ocr_api_key: str | None = None
file_parser_markitdown_ocr_base_url: str | None = None
file_parser_markitdown_ocr_model: str | None = None
file_parser_markitdown_ocr_prompt: str = DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT
# Multi-LLM chains (static, server-level). Index 0 of each chain is the
# corresponding unindexed/base LLM config above; these hold the extra indexed
# members and the routing strategy. Per-op members fall back to the global
# members when unset (see MemoryEngine._build_llm). Credential fields (members
# embed api_keys/base_urls).
llm_members: list[LLMMemberConfig] = field(default_factory=list)
llm_strategy: LLMStrategyConfig | None = None
retain_llm_members: list[LLMMemberConfig] = field(default_factory=list)
retain_llm_strategy: LLMStrategyConfig | None = None
reflect_llm_members: list[LLMMemberConfig] = field(default_factory=list)
reflect_llm_strategy: LLMStrategyConfig | None = None
consolidation_llm_members: list[LLMMemberConfig] = field(default_factory=list)
consolidation_llm_strategy: LLMStrategyConfig | None = None
# Class-level sets for configuration categorization
@@ -1994,11 +1586,6 @@ class HindsightConfig:
"retain_llm_litellmrouter_config",
"reflect_llm_litellmrouter_config",
"consolidation_llm_litellmrouter_config",
# Multi-LLM chains — members embed api_keys and base_urls
"llm_members",
"retain_llm_members",
"reflect_llm_members",
"consolidation_llm_members",
# Base URLs (could expose infrastructure)
"llm_base_url",
"retain_llm_base_url",
@@ -2007,7 +1594,6 @@ class HindsightConfig:
"embeddings_tei_base_url",
"reranker_tei_base_url",
"reranker_cohere_base_url",
"reranker_openrouter_base_url",
"embeddings_zeroentropy_base_url",
"reranker_zeroentropy_base_url",
"reranker_siliconflow_base_url",
@@ -2024,8 +1610,6 @@ class HindsightConfig:
"file_storage_gcs_service_account_key",
"file_storage_azure_account_key",
# File parser credentials
"file_parser_markitdown_ocr_api_key",
"file_parser_markitdown_ocr_base_url",
"file_parser_iris_token",
"file_parser_llama_parse_api_key",
}
@@ -2038,7 +1622,6 @@ class HindsightConfig:
"mcp_enabled_tools",
# Retention settings (behavioral)
"retain_chunk_size",
"retain_structured_chunk_size",
"retain_extraction_mode",
"retain_mission",
"retain_custom_instructions",
@@ -2058,7 +1641,6 @@ class HindsightConfig:
"consolidation_source_facts_max_tokens_per_observation",
"observations_mission",
"max_observations_per_scope",
"observation_scope_limits",
# Reflect settings
"reflect_mission",
"reflect_source_facts_max_tokens",
@@ -2082,8 +1664,6 @@ class HindsightConfig:
"disposition_empathy",
# Gemini safety settings (controls content filtering for Gemini/VertexAI providers)
"llm_gemini_safety_settings",
# Memory Defense policy (validated against DefensePolicy schema on write)
"memory_defense",
}
@property
@@ -2179,19 +1759,6 @@ class HindsightConfig:
f"Invalid semantic_min_similarity: {self.semantic_min_similarity}. Must be between 0.0 and 1.0"
)
# Validate bedrock_service_tier
valid_bedrock_tiers = (None, "flex", "priority", "reserved")
if self.llm_bedrock_service_tier not in valid_bedrock_tiers:
raise ValueError(
f"Invalid HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER: "
f"{self.llm_bedrock_service_tier!r}. Must be one of: "
f"{', '.join(t for t in valid_bedrock_tiers if t is not None)}. "
f"Note: 'standard' is not a valid Bedrock service tier -- use unset for default tier."
)
# Validate gemini_service_tier
self.llm_gemini_service_tier = parse_gemini_service_tier(self.llm_gemini_service_tier)
# When LLM provider is "none", force chunks-only mode and disable LLM-dependent features
if self.llm_provider == "none":
self.retain_extraction_mode = "chunks"
@@ -2201,23 +1768,20 @@ class HindsightConfig:
"disabling observations/consolidation. Reflect will return HTTP 400."
)
validate_retain_chunking_config(
self.retain_chunk_size,
self.retain_structured_chunk_size,
retain_chunk_size_name="HINDSIGHT_API_RETAIN_CHUNK_SIZE",
retain_structured_chunk_size_name="HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE",
)
validate_retain_completion_token_budget(
llm_provider=self.llm_provider,
retain_max_completion_tokens=self.retain_max_completion_tokens,
retain_chunk_size=self.retain_chunk_size,
retain_llm_model=self.retain_llm_model,
llm_model=self.llm_model,
retain_llm_provider=self.retain_llm_provider,
retain_max_completion_tokens_name="HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS",
retain_chunk_size_name="HINDSIGHT_API_RETAIN_CHUNK_SIZE",
)
# RETAIN_MAX_COMPLETION_TOKENS must be greater than RETAIN_CHUNK_SIZE
# to ensure the LLM has enough output capacity to extract facts from chunks
# (not applicable when provider is "none" since no LLM calls are made)
if self.llm_provider != "none" and self.retain_max_completion_tokens <= self.retain_chunk_size:
raise ValueError(
f"Invalid configuration: HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS "
f"({self.retain_max_completion_tokens}) must be greater than "
f"HINDSIGHT_API_RETAIN_CHUNK_SIZE ({self.retain_chunk_size}). "
f"\n\nYou have two options to fix this:"
f"\n 1. Increase HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS to a value > {self.retain_chunk_size}"
f"\n 2. Use a model that supports at least {self.retain_max_completion_tokens} output tokens"
f"\n (current model: {self.retain_llm_model or self.llm_model}, "
f"provider: {self.retain_llm_provider or self.llm_provider})"
)
# Warn if local ML dependencies are missing when configured.
# Don't hard-fail here — the actual ImportError fires at model init time
@@ -2308,29 +1872,9 @@ class HindsightConfig:
llm_reasoning_effort=os.getenv(ENV_LLM_REASONING_EFFORT, DEFAULT_LLM_REASONING_EFFORT),
llm_groq_service_tier=os.getenv(ENV_LLM_GROQ_SERVICE_TIER, DEFAULT_LLM_GROQ_SERVICE_TIER),
llm_openai_service_tier=os.getenv(ENV_LLM_OPENAI_SERVICE_TIER, DEFAULT_LLM_OPENAI_SERVICE_TIER),
llm_bedrock_service_tier=os.getenv(ENV_LLM_BEDROCK_SERVICE_TIER) or None,
llm_gemini_service_tier=(
parse_gemini_service_tier(os.getenv(ENV_LLM_GEMINI_SERVICE_TIER) or DEFAULT_LLM_GEMINI_SERVICE_TIER)
if llm_provider.lower() == "gemini"
else None
),
llm_extra_body=json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null")),
llm_default_headers=json.loads(os.getenv(ENV_LLM_DEFAULT_HEADERS, "null")),
llm_strict_schema=os.getenv(ENV_LLM_STRICT_SCHEMA, str(DEFAULT_LLM_STRICT_SCHEMA)).lower() in ("true", "1"),
llm_send_bank_as_user=os.getenv(ENV_LLM_SEND_BANK_AS_USER, str(DEFAULT_LLM_SEND_BANK_AS_USER)).lower()
in ("true", "1"),
llm_temperature_verification=_resolve_operation_temperature(
ENV_LLM_TEMPERATURE_VERIFICATION, DEFAULT_LLM_TEMPERATURE_VERIFICATION
),
llm_temperature_retain=_resolve_operation_temperature(
ENV_LLM_TEMPERATURE_RETAIN, DEFAULT_LLM_TEMPERATURE_RETAIN
),
llm_temperature_reflect=_resolve_operation_temperature(
ENV_LLM_TEMPERATURE_REFLECT, DEFAULT_LLM_TEMPERATURE_REFLECT
),
llm_temperature_consolidation=_resolve_operation_temperature(
ENV_LLM_TEMPERATURE_CONSOLIDATION, DEFAULT_LLM_TEMPERATURE_CONSOLIDATION
),
llm_litellmrouter_config=_parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG),
# Vertex AI
llm_vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or DEFAULT_LLM_VERTEXAI_PROJECT_ID,
@@ -2430,15 +1974,6 @@ class HindsightConfig:
if os.getenv(ENV_CONSOLIDATION_LLM_TIMEOUT)
else None,
consolidation_llm_litellmrouter_config=_parse_llm_router_config(ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG),
# Multi-LLM chains (indexed members + routing strategy)
llm_members=_parse_llm_members(""),
llm_strategy=_parse_llm_strategy(os.getenv(ENV_LLM_STRATEGY)),
retain_llm_members=_parse_llm_members("RETAIN_"),
retain_llm_strategy=_parse_llm_strategy(os.getenv(ENV_RETAIN_LLM_STRATEGY)),
reflect_llm_members=_parse_llm_members("REFLECT_"),
reflect_llm_strategy=_parse_llm_strategy(os.getenv(ENV_REFLECT_LLM_STRATEGY)),
consolidation_llm_members=_parse_llm_members("CONSOLIDATION_"),
consolidation_llm_strategy=_parse_llm_strategy(os.getenv(ENV_CONSOLIDATION_LLM_STRATEGY)),
# Embeddings
embeddings_provider=os.getenv(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER),
embeddings_local_model=os.getenv(ENV_EMBEDDINGS_LOCAL_MODEL, DEFAULT_EMBEDDINGS_LOCAL_MODEL),
@@ -2503,11 +2038,6 @@ class HindsightConfig:
or os.getenv(ENV_OPENROUTER_API_KEY)
or os.getenv(ENV_LLM_API_KEY),
embeddings_openrouter_model=os.getenv(ENV_EMBEDDINGS_OPENROUTER_MODEL, DEFAULT_EMBEDDINGS_OPENROUTER_MODEL),
# Requesty embeddings (with fallback to shared Requesty key, then LLM key)
embeddings_requesty_api_key=os.getenv(ENV_EMBEDDINGS_REQUESTY_API_KEY)
or os.getenv(ENV_REQUESTY_API_KEY)
or os.getenv(ENV_LLM_API_KEY),
embeddings_requesty_model=os.getenv(ENV_EMBEDDINGS_REQUESTY_MODEL, DEFAULT_EMBEDDINGS_REQUESTY_MODEL),
# ZeroEntropy embeddings
embeddings_zeroentropy_api_key=os.getenv(ENV_EMBEDDINGS_ZEROENTROPY_API_KEY)
or os.getenv("ZEROENTROPY_API_KEY"),
@@ -2614,15 +2144,6 @@ class HindsightConfig:
recall_strategy_boosts=_parse_strategy_boosts(
os.getenv(ENV_RECALL_STRATEGY_BOOSTS, DEFAULT_RECALL_STRATEGY_BOOSTS)
),
recency_decay_function=_validate_recency_decay_function(
os.getenv(ENV_RECENCY_DECAY_FUNCTION, DEFAULT_RECENCY_DECAY_FUNCTION)
),
recency_decay_linear_window_days=float(
os.getenv(ENV_RECENCY_DECAY_LINEAR_WINDOW_DAYS, str(DEFAULT_RECENCY_DECAY_LINEAR_WINDOW_DAYS))
),
recency_decay_halflife_days=float(
os.getenv(ENV_RECENCY_DECAY_HALFLIFE_DAYS, str(DEFAULT_RECENCY_DECAY_HALFLIFE_DAYS))
),
# Cohere reranker (with backward-compatible fallback to shared API key)
reranker_cohere_api_key=os.getenv(ENV_RERANKER_COHERE_API_KEY) or os.getenv(ENV_COHERE_API_KEY),
reranker_cohere_model=os.getenv(ENV_RERANKER_COHERE_MODEL, DEFAULT_RERANKER_COHERE_MODEL),
@@ -2633,9 +2154,6 @@ class HindsightConfig:
or os.getenv(ENV_OPENROUTER_API_KEY)
or os.getenv(ENV_LLM_API_KEY),
reranker_openrouter_model=os.getenv(ENV_RERANKER_OPENROUTER_MODEL, DEFAULT_RERANKER_OPENROUTER_MODEL),
reranker_openrouter_base_url=os.getenv(
ENV_RERANKER_OPENROUTER_BASE_URL, DEFAULT_RERANKER_OPENROUTER_BASE_URL
),
reranker_openrouter_timeout=float(
os.getenv(ENV_RERANKER_OPENROUTER_TIMEOUT, str(DEFAULT_RERANKER_OPENROUTER_TIMEOUT))
),
@@ -2698,13 +2216,8 @@ class HindsightConfig:
if os.getenv(ENV_MCP_ENABLED_TOOLS)
else DEFAULT_MCP_ENABLED_TOOLS,
mcp_stateless=os.getenv(ENV_MCP_STATELESS, str(DEFAULT_MCP_STATELESS)).lower() == "true",
mcp_instructions=os.getenv(ENV_MCP_INSTRUCTIONS) or DEFAULT_MCP_INSTRUCTIONS,
enable_bank_llm_health=os.getenv(ENV_ENABLE_BANK_LLM_HEALTH, str(DEFAULT_ENABLE_BANK_LLM_HEALTH)).lower()
== "true",
enable_bank_config_api=os.getenv(ENV_ENABLE_BANK_CONFIG_API, str(DEFAULT_ENABLE_BANK_CONFIG_API)).lower()
== "true",
enable_dry_run_extract=os.getenv(ENV_ENABLE_DRY_RUN_EXTRACT, str(DEFAULT_ENABLE_DRY_RUN_EXTRACT)).lower()
== "true",
default_bank_template=_parse_default_bank_template(os.getenv(ENV_DEFAULT_BANK_TEMPLATE)),
# Recall
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
@@ -2728,15 +2241,12 @@ class HindsightConfig:
),
# Optimization flags
skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true",
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",
# Retain settings
retain_max_completion_tokens=int(
os.getenv(ENV_RETAIN_MAX_COMPLETION_TOKENS, str(DEFAULT_RETAIN_MAX_COMPLETION_TOKENS))
),
retain_chunk_size=int(os.getenv(ENV_RETAIN_CHUNK_SIZE, str(DEFAULT_RETAIN_CHUNK_SIZE))),
retain_structured_chunk_size=_parse_optional_positive_int(
ENV_RETAIN_STRUCTURED_CHUNK_SIZE,
os.getenv(ENV_RETAIN_STRUCTURED_CHUNK_SIZE),
),
retain_extract_causal_links=os.getenv(
ENV_RETAIN_EXTRACT_CAUSAL_LINKS, str(DEFAULT_RETAIN_EXTRACT_CAUSAL_LINKS)
).lower()
@@ -2777,18 +2287,6 @@ class HindsightConfig:
file_parser_allowlist=_parse_str_list(os.getenv(ENV_FILE_PARSER_ALLOWLIST))
if os.getenv(ENV_FILE_PARSER_ALLOWLIST)
else None,
file_parser_markitdown_ocr_enabled=os.getenv(
ENV_FILE_PARSER_MARKITDOWN_OCR_ENABLED,
str(DEFAULT_FILE_PARSER_MARKITDOWN_OCR_ENABLED),
).lower()
in ("1", "true", "yes", "on"),
file_parser_markitdown_ocr_api_key=os.getenv(ENV_FILE_PARSER_MARKITDOWN_OCR_API_KEY) or None,
file_parser_markitdown_ocr_base_url=os.getenv(ENV_FILE_PARSER_MARKITDOWN_OCR_BASE_URL) or None,
file_parser_markitdown_ocr_model=os.getenv(ENV_FILE_PARSER_MARKITDOWN_OCR_MODEL) or None,
file_parser_markitdown_ocr_prompt=os.getenv(
ENV_FILE_PARSER_MARKITDOWN_OCR_PROMPT,
DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT,
),
file_parser_iris_token=os.getenv(ENV_FILE_PARSER_IRIS_TOKEN) or None,
file_parser_iris_org_id=os.getenv(ENV_FILE_PARSER_IRIS_ORG_ID) or None,
file_parser_llama_parse_api_key=os.getenv(ENV_FILE_PARSER_LLAMA_PARSE_API_KEY) or None,
@@ -2804,7 +2302,6 @@ class HindsightConfig:
ENV_FILE_DELETE_AFTER_RETAIN, str(DEFAULT_FILE_DELETE_AFTER_RETAIN)
).lower()
== "true",
store_document_text=os.getenv(ENV_STORE_DOCUMENT_TEXT, str(DEFAULT_STORE_DOCUMENT_TEXT)).lower() == "true",
enable_document_export_api=os.getenv(
ENV_ENABLE_DOCUMENT_EXPORT_API, str(DEFAULT_ENABLE_DOCUMENT_EXPORT_API)
).lower()
@@ -2888,14 +2385,10 @@ class HindsightConfig:
max_observations_per_scope=int(
os.getenv(ENV_MAX_OBSERVATIONS_PER_SCOPE, str(DEFAULT_MAX_OBSERVATIONS_PER_SCOPE))
),
observation_scope_limits=json.loads(os.getenv(ENV_OBSERVATION_SCOPE_LIMITS, "null"))
or DEFAULT_OBSERVATION_SCOPE_LIMITS,
entity_labels=None,
entities_allow_free_form=True,
memory_defense=None,
# Database migrations
run_migrations_on_startup=os.getenv(ENV_RUN_MIGRATIONS_ON_STARTUP, "true").lower() == "true",
migration_concurrency=int(os.getenv(ENV_MIGRATION_CONCURRENCY, str(DEFAULT_MIGRATION_CONCURRENCY))),
# Database connection pool
db_pool_min_size=int(os.getenv(ENV_DB_POOL_MIN_SIZE, str(DEFAULT_DB_POOL_MIN_SIZE))),
db_pool_max_size=int(os.getenv(ENV_DB_POOL_MAX_SIZE, str(DEFAULT_DB_POOL_MAX_SIZE))),
@@ -2979,8 +2472,6 @@ class HindsightConfig:
otel_deployment_environment=os.getenv(ENV_OTEL_DEPLOYMENT_ENVIRONMENT, DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT),
metrics_include_bank_id=os.getenv(ENV_METRICS_INCLUDE_BANK_ID, str(DEFAULT_METRICS_INCLUDE_BANK_ID)).lower()
in ("true", "1", "yes"),
metrics_backlog_enabled=os.getenv(ENV_METRICS_BACKLOG_ENABLED, str(DEFAULT_METRICS_BACKLOG_ENABLED)).lower()
in ("true", "1", "yes"),
# Audit log configuration (static, server-level only)
audit_log_enabled=os.getenv(ENV_AUDIT_LOG_ENABLED, str(DEFAULT_AUDIT_LOG_ENABLED)).lower() == "true",
audit_log_actions=[
@@ -3005,12 +2496,6 @@ class HindsightConfig:
str(DEFAULT_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS),
)
),
mental_model_refresh_tick_seconds=int(
os.getenv(
ENV_MENTAL_MODEL_REFRESH_TICK_SECONDS,
str(DEFAULT_MENTAL_MODEL_REFRESH_TICK_SECONDS),
)
),
# Webhook configuration (static, server-level only)
webhook_url=os.getenv(ENV_WEBHOOK_URL) or DEFAULT_WEBHOOK_URL,
webhook_secret=os.getenv(ENV_WEBHOOK_SECRET) or DEFAULT_WEBHOOK_SECRET,
@@ -8,7 +8,6 @@ Config values are resolved on every request to ensure consistency across
multiple API servers.
"""
import asyncio
import json
import logging
from dataclasses import asdict, replace
@@ -19,8 +18,6 @@ from hindsight_api.config import (
HindsightConfig,
_get_raw_config,
normalize_config_dict,
validate_retain_chunking_config,
validate_retain_completion_token_budget,
)
from hindsight_api.engine.memory_engine import fq_table
from hindsight_api.extensions.tenant import TenantExtension
@@ -32,35 +29,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _validate_retain_strategy_chunking(base_config: HindsightConfig, strategies: Any) -> None:
"""Validate retain strategy chunking with the same semantics as apply_strategy()."""
if not isinstance(strategies, dict):
return
configurable = HindsightConfig.get_configurable_fields()
for strategy_name, overrides in strategies.items():
if not isinstance(overrides, dict):
raise ValueError(f"Invalid retain strategy {strategy_name!r}: must be an object")
filtered = {k: v for k, v in overrides.items() if k in configurable}
if not filtered:
continue
try:
resolved = replace(base_config, **filtered)
validate_retain_chunking_config(
resolved.retain_chunk_size,
resolved.retain_structured_chunk_size,
)
validate_retain_completion_token_budget(
llm_provider=resolved.llm_provider,
retain_max_completion_tokens=resolved.retain_max_completion_tokens,
retain_chunk_size=resolved.retain_chunk_size,
retain_llm_model=resolved.retain_llm_model,
llm_model=resolved.llm_model,
retain_llm_provider=resolved.retain_llm_provider,
)
except ValueError as e:
raise ValueError(f"Invalid retain strategy {strategy_name!r}: {e}") from e
class ConfigResolver:
"""Resolves hierarchical configuration with tenant/bank overrides."""
@@ -78,26 +46,6 @@ class ConfigResolver:
self._configurable_fields = HindsightConfig.get_configurable_fields()
self._credential_fields = HindsightConfig.get_credential_fields()
async def _resolve_parent_config_dict(self, bank_id: str, context: RequestContext | None = None) -> dict[str, Any]:
"""Resolve global + tenant config before bank-level overrides."""
config_dict = asdict(self._global_config)
if self.tenant_extension and context:
try:
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
if tenant_overrides:
# Normalize keys and filter to configurable fields only
normalized_tenant = normalize_config_dict(tenant_overrides)
configurable_tenant = {k: v for k, v in normalized_tenant.items() if k in self._configurable_fields}
config_dict.update(configurable_tenant)
logger.debug(
f"Applied tenant config overrides for bank {bank_id}: {list(configurable_tenant.keys())}"
)
except Exception as e:
logger.warning(f"Failed to load tenant config for bank {bank_id}: {e}")
return config_dict
async def resolve_full_config(self, bank_id: str, context: RequestContext | None = None) -> HindsightConfig:
"""
Resolve full HindsightConfig for a bank with hierarchical overrides applied.
@@ -117,7 +65,23 @@ class ConfigResolver:
Returns:
Complete HindsightConfig with hierarchical overrides applied
"""
config_dict = await self._resolve_parent_config_dict(bank_id, context)
# Start with global config (all fields)
config_dict = asdict(self._global_config)
# Load tenant config overrides (if tenant extension available)
if self.tenant_extension and context:
try:
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
if tenant_overrides:
# Normalize keys and filter to configurable fields only
normalized_tenant = normalize_config_dict(tenant_overrides)
configurable_tenant = {k: v for k, v in normalized_tenant.items() if k in self._configurable_fields}
config_dict.update(configurable_tenant)
logger.debug(
f"Applied tenant config overrides for bank {bank_id}: {list(configurable_tenant.keys())}"
)
except Exception as e:
logger.warning(f"Failed to load tenant config for bank {bank_id}: {e}")
# Load bank config overrides
bank_overrides = await self._load_bank_config(bank_id)
@@ -128,25 +92,6 @@ class ConfigResolver:
# Return full config object (dataclass doesn't have __init__ that accepts kwargs, so we update the object)
# Create a new config instance by copying the global config and updating fields
resolved_config = HindsightConfig(**config_dict)
# Multi-LLM chains are static credential fields (never tenant/bank-overridable),
# but asdict() above flattened their member dataclasses into plain dicts. Restore
# the original typed objects from the global config so the resolved object stays
# well-typed for any consumer that reads them.
resolved_config = replace(
resolved_config,
llm_members=self._global_config.llm_members,
llm_strategy=self._global_config.llm_strategy,
retain_llm_members=self._global_config.retain_llm_members,
retain_llm_strategy=self._global_config.retain_llm_strategy,
reflect_llm_members=self._global_config.reflect_llm_members,
reflect_llm_strategy=self._global_config.reflect_llm_strategy,
consolidation_llm_members=self._global_config.consolidation_llm_members,
consolidation_llm_strategy=self._global_config.consolidation_llm_strategy,
)
validate_retain_chunking_config(
resolved_config.retain_chunk_size,
resolved_config.retain_structured_chunk_size,
)
return resolved_config
async def get_bank_config(self, bank_id: str, context: RequestContext | None = None) -> dict[str, Any]:
@@ -177,83 +122,26 @@ class ConfigResolver:
resolved_config = await self.resolve_full_config(bank_id, context)
config_dict = asdict(resolved_config)
# SECURITY: drop static/infrastructure + credential fields, then permission-filter.
filtered = self._strip_static_and_credential_fields(config_dict)
return await self._apply_permission_filter(filtered, bank_id, context)
# SECURITY: Filter to only configurable fields (exclude static/infrastructure)
filtered = {k: v for k, v in config_dict.items() if k in self._configurable_fields}
def _strip_static_and_credential_fields(self, config_dict: dict[str, Any]) -> dict[str, Any]:
"""Keep only configurable, non-credential fields.
# SECURITY: Remove ALL credential fields (API keys, base URLs, etc.)
filtered = {k: v for k, v in filtered.items() if k not in self._credential_fields}
SECURITY: excludes static/infrastructure fields and ALL credential fields
(API keys, base URLs, etc.) so a resolved config is safe to return over the API.
"""
return {
k: v for k, v in config_dict.items() if k in self._configurable_fields and k not in self._credential_fields
}
async def _apply_permission_filter(
self, filtered: dict[str, Any], bank_id: str, context: RequestContext | None
) -> dict[str, Any]:
"""Further restrict already-stripped config to the tenant/bank permission allow-list.
On extension error, leaves ``filtered`` unchanged (parity with the historical
single-bank path: a permissions lookup failure must not leak or drop fields).
"""
if not (self.tenant_extension and context):
return filtered
try:
allowed_fields = await self.tenant_extension.get_allowed_config_fields(context, bank_id)
if allowed_fields is not None: # None means "allow all"
filtered = {k: v for k, v in filtered.items() if k in allowed_fields}
logger.debug(
f"Applied permission filter for bank {bank_id}: allowed={len(allowed_fields)} fields, "
f"returned={len(filtered)} fields"
)
except Exception as e:
logger.warning(f"Failed to load permissions for bank {bank_id}: {e}")
return filtered
async def get_bank_configs(
self, bank_ids: list[str], context: RequestContext | None = None
) -> dict[str, dict[str, Any]]:
"""Batch variant of :meth:`get_bank_config` for many banks.
Equivalent to calling ``get_bank_config`` per bank, but resolves the
global + tenant base once and loads every bank's ``banks.config`` JSONB
in a single query, instead of one config round-trip per bank. Used by
``list_banks`` to overlay disposition + mission without an N+1.
Returns a mapping of bank_id -> filtered configurable-field dict. A bank
with no config row still appears, mapped to the global+tenant base.
"""
if not bank_ids:
return {}
# Global + tenant base, resolved once (tenant override is per-request, not per-bank).
base_dict = asdict(self._global_config)
# PERMISSIONS: Further filter based on tenant/bank permissions
if self.tenant_extension and context:
try:
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
if tenant_overrides:
normalized_tenant = normalize_config_dict(tenant_overrides)
base_dict.update({k: v for k, v in normalized_tenant.items() if k in self._configurable_fields})
allowed_fields = await self.tenant_extension.get_allowed_config_fields(context, bank_id)
if allowed_fields is not None: # None means "allow all"
filtered = {k: v for k, v in filtered.items() if k in allowed_fields}
logger.debug(
f"Applied permission filter for bank {bank_id}: allowed={len(allowed_fields)} fields, "
f"returned={len(filtered)} fields"
)
except Exception as e:
logger.warning(f"Failed to load tenant config for bulk resolve: {e}")
logger.warning(f"Failed to load permissions for bank {bank_id}: {e}")
# All bank overrides in one query, then merge + strip per bank.
bank_overrides = await self._load_bank_configs(bank_ids)
stripped = {
bank_id: self._strip_static_and_credential_fields({**base_dict, **bank_overrides.get(bank_id, {})})
for bank_id in bank_ids
}
# Permission filter is per-bank; resolve concurrently when an extension is present.
if not (self.tenant_extension and context):
return stripped
permission_filtered = await asyncio.gather(
*(self._apply_permission_filter(stripped[bank_id], bank_id, context) for bank_id in bank_ids)
)
return dict(zip(bank_ids, permission_filtered, strict=True))
return filtered
async def _load_bank_config(self, bank_id: str) -> dict[str, Any]:
"""
@@ -292,45 +180,6 @@ class ConfigResolver:
return {}
async def _load_bank_configs(self, bank_ids: list[str]) -> dict[str, dict[str, Any]]:
"""Bulk variant of :meth:`_load_bank_config`: load many banks' overrides in one query.
Returns a mapping of bank_id -> normalized active overrides. Banks with no row
(or an empty/all-tombstone config) are simply absent from the mapping.
"""
result: dict[str, dict[str, Any]] = {}
if not bank_ids:
return result
try:
async with self._backend.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT bank_id, config FROM {fq_table("banks")} WHERE bank_id = ANY($1)
""",
bank_ids,
)
for row in rows:
config_data = row["config"]
if not config_data:
continue
# Handle case where JSONB is returned as JSON string
if isinstance(config_data, str):
config_data = json.loads(config_data)
# Normalize keys (handle both env var format and Python field format)
normalized = normalize_config_dict(config_data)
# Only active overrides for configurable fields. JSON null is a tombstone
# for "Server Default" in the bank-config UI and must not override defaults.
overrides = {
k: v for k, v in normalized.items() if k in self._configurable_fields and v is not None
}
if overrides:
result[row["bank_id"]] = overrides
except Exception as e:
logger.error(f"Failed to bulk-load bank configs: {e}")
return result
async def update_bank_config(
self, bank_id: str, updates: dict[str, Any], context: RequestContext | None = None
) -> None:
@@ -417,32 +266,6 @@ class ConfigResolver:
# Validate recall budget fields
_validate_recall_budget_updates(normalized_updates)
# Validate disposition trait fields (1-5 integer scale)
_validate_disposition_updates(normalized_updates)
chunking_fields_updated = (
"retain_chunk_size" in normalized_updates
or "retain_structured_chunk_size" in normalized_updates
or "retain_strategies" in normalized_updates
)
if chunking_fields_updated:
config_dict = await self._resolve_parent_config_dict(bank_id, context)
active_bank_overrides = await self._load_bank_config(bank_id)
for key, value in normalized_updates.items():
if key not in self._configurable_fields:
continue
if value is None:
active_bank_overrides.pop(key, None)
else:
active_bank_overrides[key] = value
config_dict.update(active_bank_overrides)
base_config = HindsightConfig(**config_dict)
validate_retain_chunking_config(
base_config.retain_chunk_size,
base_config.retain_structured_chunk_size,
)
_validate_retain_strategy_chunking(base_config, base_config.retain_strategies)
# Persist the override. Banks are created lazily (on first retain), so a
# PATCH that precedes any ingestion would otherwise UPDATE zero rows and
# silently no-op while returning 200. Ensure the bank row exists first
@@ -534,31 +357,6 @@ def _validate_recall_budget_updates(updates: dict[str, Any]) -> None:
)
_DISPOSITION_KEYS = (
"disposition_skepticism",
"disposition_literalism",
"disposition_empathy",
)
def _validate_disposition_updates(updates: dict[str, Any]) -> None:
"""Validate disposition trait config updates. Raises ValueError on invalid input.
Each trait is an integer on a 1-5 scale (or None to clear the per-bank
override). The read overlay injects the stored value verbatim into a strict
``DispositionTraits(int, ge=1, le=5)``; an out-of-contract value (a float, a
0-1 scale, or an int outside 1-5) accepted here would later 500 the whole
bank list when any bank profile is serialized (issue #2348).
"""
for key in _DISPOSITION_KEYS:
if key in updates:
value = updates[key]
if value is None:
continue
if not isinstance(value, int) or isinstance(value, bool) or not (1 <= value <= 5):
raise ValueError(f"{key} must be an integer between 1 and 5, got {value!r}")
def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConfig:
"""
Apply a named retain strategy's overrides on top of a resolved config.
@@ -566,8 +364,7 @@ def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConf
A strategy is a named set of hierarchical field overrides stored in
config.retain_strategies. Any field in _HIERARCHICAL_FIELDS can be
overridden, including retain_extraction_mode, retain_chunk_size,
retain_structured_chunk_size, entity_labels,
entities_allow_free_form, etc.
entity_labels, entities_allow_free_form, etc.
Unknown strategy names log a warning and return config unchanged.
Unknown or non-hierarchical fields in the strategy are silently ignored.
@@ -589,17 +386,4 @@ def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConf
return config
logger.debug(f"Applying retain strategy '{strategy_name}': {list(filtered.keys())}")
resolved = replace(config, **filtered)
validate_retain_chunking_config(
resolved.retain_chunk_size,
resolved.retain_structured_chunk_size,
)
validate_retain_completion_token_budget(
llm_provider=resolved.llm_provider,
retain_max_completion_tokens=resolved.retain_max_completion_tokens,
retain_chunk_size=resolved.retain_chunk_size,
retain_llm_model=resolved.retain_llm_model,
llm_model=resolved.llm_model,
retain_llm_provider=resolved.retain_llm_provider,
)
return resolved
return replace(config, **filtered)
@@ -1,34 +0,0 @@
"""Per-bank provider cost attribution via the OpenAI ``user`` field.
Shared by the OpenAI-compatible LLM path and the OpenAI embeddings path so both
tag outbound requests identically. Opt-in via ``HINDSIGHT_API_LLM_SEND_BANK_AS_USER``;
downstream cost gateways (OpenRouter usage accounting, LiteLLM, Helicone) key spend
on the OpenAI ``user`` field.
Note: when enabled, the bank id is transmitted to the upstream provider as the
end-user identifier. Banks that are themselves end-user identifiers are therefore
forwarded to the provider — which is exactly what the OpenAI ``user`` field is for,
but operators should opt in with that in mind.
"""
from typing import Any
def apply_bank_attribution(request: dict[str, Any]) -> None:
"""Tag ``request`` with ``user=<bank_id>`` for per-bank cost attribution.
Mutates ``request`` in place. No-op when the flag is off, no bank is in context,
or the caller already set ``user`` — we never override an explicit value.
"""
if "user" in request:
return
# Lazy imports: memory_engine imports the embeddings/provider modules that call
# this, so a top-level import of memory_engine here would be circular.
from ..config import get_config
from .memory_engine import get_current_bank_id
if not get_config().llm_send_bank_as_user:
return
bank_id = get_current_bank_id()
if bank_id:
request["user"] = bank_id
@@ -13,18 +13,9 @@ in-flight task so that N concurrent callers produce one query rather than N.
from __future__ import annotations
import asyncio
import json
import logging
import time
from collections import OrderedDict
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from .db_utils import acquire_with_retry
if TYPE_CHECKING:
from .db.base import DatabaseBackend
logger = logging.getLogger(__name__)
from typing import Any, Awaitable, Callable
class BankStatsCache:
@@ -75,28 +66,17 @@ class BankStatsCache:
schema: str,
bank_id: str,
loader: Callable[[], Awaitable[dict[str, Any]]],
*,
force_refresh: bool = False,
) -> dict[str, Any]:
"""Return cached stats for `(schema, bank_id)` or call `loader()`.
Concurrent misses on the same key are coalesced onto a single
in-flight loader. When ``force_refresh`` is set the cached value is
ignored: the loader runs and its result replaces the cached entry.
in-flight loader.
"""
if not self.enabled:
return await loader()
key = (schema, bank_id)
if force_refresh:
value = await loader()
async with self._lock:
self._store_unlocked(key, value)
# Supersede any loader that was in flight for this key.
self._in_flight.pop(key, None)
return value
async with self._lock:
cached = self._get_fresh_unlocked(key)
if cached is not None:
@@ -116,10 +96,7 @@ class BankStatsCache:
value = await loader()
except BaseException as exc:
async with self._lock:
# Invalidation may have detached this loader and allowed a new
# one to claim the key. Never remove that newer loader's slot.
if self._in_flight.get(key) is in_flight:
self._in_flight.pop(key, None)
self._in_flight.pop(key, None)
if not in_flight.done():
in_flight.set_exception(exc)
# Suppress "Future exception was never retrieved" when no other
@@ -129,12 +106,8 @@ class BankStatsCache:
raise
async with self._lock:
# Only the loader that still owns the key may populate the cache.
# An invalidated loader can finish for its original callers, but its
# pre-invalidation result must not overwrite a newer load.
if self._in_flight.get(key) is in_flight:
self._store_unlocked(key, value)
self._in_flight.pop(key, None)
self._store_unlocked(key, value)
self._in_flight.pop(key, None)
if not in_flight.done():
in_flight.set_result(value)
return value
@@ -142,113 +115,8 @@ class BankStatsCache:
async def invalidate(self, schema: str, bank_id: str) -> None:
"""Drop any cached stats for `(schema, bank_id)`."""
async with self._lock:
key = (schema, bank_id)
self._entries.pop(key, None)
# Detach rather than cancel: existing callers may finish with the
# snapshot they requested, while post-invalidation callers reload.
self._in_flight.pop(key, None)
self._entries.pop((schema, bank_id), None)
async def clear(self) -> None:
async with self._lock:
self._entries.clear()
self._in_flight.clear()
class DistributedBankStatsCache:
"""Table-backed (cross-process) TTL cache for `get_bank_stats`.
Same ``get_or_load`` / ``invalidate`` / ``clear`` contract as
:class:`BankStatsCache`, but the store is the per-schema ``bank_stats_cache``
table instead of a per-process dict — so one worker's computation is shared
with every other worker, and no caller recomputes while a fresh row exists.
On a hit, a call is a single primary-key ``SELECT`` (sub-millisecond); only a
miss runs the (expensive) ``loader`` and writes the row back. Concurrent
misses are *not* coalesced across processes (that would need a lock): they
each compute and ``UPSERT``, last write wins — all results are correct, at the
cost of a brief redundant compute at expiry.
Every DB touch is best-effort: if the cache table is unreachable or missing
(e.g. a schema mid-migration), the call degrades to computing without caching
rather than failing ``get_bank_stats``. PostgreSQL only — the engine keeps the
in-process :class:`BankStatsCache` for Oracle.
"""
def __init__(self, *, backend: "DatabaseBackend", ttl_seconds: float) -> None:
self._backend = backend
self._ttl = float(ttl_seconds)
@property
def enabled(self) -> bool:
return self._ttl > 0
@staticmethod
def _qualified(schema: str) -> str:
return f'"{schema}".bank_stats_cache' if schema else "bank_stats_cache"
async def get_or_load(
self,
schema: str,
bank_id: str,
loader: Callable[[], Awaitable[dict[str, Any]]],
*,
force_refresh: bool = False,
) -> dict[str, Any]:
if not self.enabled:
return await loader()
table = self._qualified(schema)
# 1. Fresh row? Single PK lookup; ``payload::text`` sidesteps any
# jsonb->object codec so we always decode the same way. Skipped when
# the caller forces a refresh — then we recompute and overwrite below.
if not force_refresh:
try:
async with acquire_with_retry(self._backend) as conn:
row = await conn.fetchrow(
f"SELECT payload::text AS payload FROM {table} "
f"WHERE bank_id = $1 AND computed_at > now() - make_interval(secs => $2::double precision)",
bank_id,
self._ttl,
)
if row is not None:
return json.loads(row["payload"])
except Exception as exc: # noqa: BLE001 — cache read must never break the endpoint
logger.debug("bank_stats_cache read failed for %s.%s (%s); computing uncached", schema, bank_id, exc)
return await loader()
# 2. Miss — compute, then write the row back (best-effort).
value = await loader()
try:
async with acquire_with_retry(self._backend) as conn:
await conn.execute(
f"INSERT INTO {table} (bank_id, payload, computed_at) VALUES ($1, $2::jsonb, now()) "
f"ON CONFLICT (bank_id) DO UPDATE SET payload = EXCLUDED.payload, computed_at = now()",
bank_id,
json.dumps(value),
)
except Exception as exc: # noqa: BLE001 — a failed write just means no caching this round
logger.warning("bank_stats_cache write failed for %s.%s (%s)", schema, bank_id, exc)
return value
async def invalidate(self, schema: str, bank_id: str) -> None:
"""Drop the cached row so the next read recomputes."""
if not self.enabled:
return
try:
async with acquire_with_retry(self._backend) as conn:
await conn.execute(f"DELETE FROM {self._qualified(schema)} WHERE bank_id = $1", bank_id)
except Exception as exc: # noqa: BLE001 — invalidation must never break the write path
logger.debug("bank_stats_cache invalidate failed for %s.%s (%s)", schema, bank_id, exc)
async def clear(self) -> None:
"""Drop all cached rows in the current schema (best-effort)."""
if not self.enabled:
return
from .memory_engine import get_current_schema
try:
async with acquire_with_retry(self._backend) as conn:
await conn.execute(f"DELETE FROM {self._qualified(get_current_schema())}")
except Exception as exc: # noqa: BLE001
logger.debug("bank_stats_cache clear failed (%s)", exc)
File diff suppressed because it is too large Load Diff
@@ -24,7 +24,6 @@ from collections import defaultdict
from contextlib import AsyncExitStack
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from fnmatch import fnmatchcase
from itertools import combinations
from typing import TYPE_CHECKING, Any, Literal
@@ -98,7 +97,7 @@ _DEDUP_TOP_K = 5
class _DedupDecision(BaseModel):
"""Focused 1-by-1 verdict for whether a new observation duplicates an existing one."""
action: Literal["merge", "keep"] = "keep"
action: Literal["merge", "keep"]
text: str = "" # the synthesized merged observation (when action == "merge")
reason: str = ""
@@ -224,18 +223,13 @@ async def _dedup_reconcile_create(
# Fold the new source facts into the twin and persist the merged text. We keep the twin's
# existing embedding: the merged text is >= threshold similar, so the stored vector stays
# representative and we avoid a re-embed + a dialect-specific vector UPDATE.
search_vector_clause = (
f",\n search_vector = to_tsvector('{config.text_search_extension_native_language}'::regconfig, COALESCE($1, ''))"
if config.text_search_extension == "native"
else ""
)
await conn.execute(
f"""
UPDATE {fq_table("memory_units")}
SET text = $1,
source_memory_ids = (SELECT array_agg(DISTINCT e) FROM unnest(source_memory_ids || $2::uuid[]) e),
proof_count = (SELECT count(DISTINCT e) FROM unnest(source_memory_ids || $2::uuid[]) e),
updated_at = now(){search_vector_clause}
updated_at = now()
WHERE id = $3::uuid
""",
outcome.merged_text,
@@ -284,11 +278,6 @@ async def _dedup_reconcile_update(
# the create path) then delete the now-redundant updated row. The all_strict/any tag match
# guarantees twin and updated share scope, so dropping the updated row's tags loses no
# visibility. Temporal fields follow the surviving twin (minimal scope; matches create).
search_vector_clause = (
f",\n search_vector = to_tsvector('{config.text_search_extension_native_language}'::regconfig, COALESCE($1, ''))"
if config.text_search_extension == "native"
else ""
)
await conn.execute(
f"""
UPDATE {fq_table("memory_units")} t
@@ -299,7 +288,7 @@ async def _dedup_reconcile_update(
proof_count = (
SELECT count(DISTINCT e) FROM unnest(t.source_memory_ids || u.source_memory_ids) e
),
updated_at = now(){search_vector_clause}
updated_at = now()
FROM {fq_table("memory_units")} u
WHERE t.id = $2::uuid AND u.id = $3::uuid
""",
@@ -345,15 +334,7 @@ def _resolve_obs_tags_list(memory: dict[str, Any]) -> list[list[str]] | None:
Returns ``None`` for the default ``combined``-mode single pass (caller uses
the memory's own tags). Returns a list[list[str]] when the memory requested
multi-pass scoping (``per_tag``, ``all_combinations``, ``shared``, or an
explicit list).
``shared`` resolves to ``[[]]`` — a single pass over the empty (untagged)
scope. The created observation carries no tags and recall/dedup match it with
``tags_match="any"``, so every memory consolidates into one shared observation
regardless of its own tags. Use it to deduplicate across volatile per-call
provenance tags (e.g. per-session ids) without dropping those tags from the
source facts.
multi-pass scoping (``per_tag``, ``all_combinations``, or an explicit list).
"""
parsed = _parse_observation_scopes(memory)
tags = list(memory.get("tags") or [])
@@ -364,8 +345,6 @@ def _resolve_obs_tags_list(memory: dict[str, Any]) -> list[list[str]] | None:
if not tags:
return None
return [list(c) for r in range(1, len(tags) + 1) for c in combinations(tags, r)]
if parsed == "shared":
return [[]]
if parsed == "combined" or parsed is None:
return None
return parsed # explicit list[list[str]]
@@ -382,7 +361,6 @@ def _resolve_write_scopes(memory: dict[str, Any]) -> list[frozenset[str]]:
- ``combined`` / ``None`` -> ``[frozenset(memory.tags)]``
- ``per_tag`` -> ``[frozenset({t}) for t in memory.tags]``
- ``all_combinations`` -> one frozenset per nonempty subset of tags
- ``shared`` -> ``[frozenset()]`` (the single untagged scope)
- explicit ``list[list[str]]`` -> one frozenset per declared scope
Empty-tag memories collapse to a single ``frozenset()`` in all modes so they
@@ -397,8 +375,6 @@ def _resolve_write_scopes(memory: dict[str, Any]) -> list[frozenset[str]]:
if not tags:
return [frozenset()]
return [frozenset(c) for r in range(1, len(tags) + 1) for c in combinations(tags, r)]
if parsed == "shared":
return [frozenset()]
if parsed == "combined" or parsed is None:
return [frozenset(tags)]
return [frozenset(s) for s in parsed] # explicit list[list[str]]
@@ -459,13 +435,6 @@ class _CreateAction(BaseModel):
def sanitize_text(cls, v: str) -> str:
return sanitize_llm_output(v) or ""
@field_validator("source_fact_ids", mode="before")
@classmethod
def ensure_list(cls, v: str | list[str]) -> list[str]:
if isinstance(v, str):
return [v]
return v
class _UpdateAction(BaseModel):
text: str
@@ -478,13 +447,6 @@ class _UpdateAction(BaseModel):
def sanitize_text(cls, v: str) -> str:
return sanitize_llm_output(v) or ""
@field_validator("source_fact_ids", mode="before")
@classmethod
def ensure_list(cls, v: str | list[str]) -> list[str]:
if isinstance(v, str):
return [v]
return v
class _DeleteAction(BaseModel):
observation_id: str # UUID of the observation to remove
@@ -561,86 +523,6 @@ async def _count_observations_for_scope(
)
@dataclass(frozen=True)
class _ScopeLimitRule:
"""One ``observation_scope_limits`` rule: a scope pattern -> an observation cap.
``globs`` is a tuple of fnmatch tag-globs describing one consolidation scope.
A concrete scope (the set of ``fact_tags`` for a consolidation pass) matches
under *exact cover*: every tag is matched by some glob AND every glob matches
some tag. So ``["shared"]`` matches the scope ``{shared}`` but not
``{run_1, shared}``, and ``["run_*", "shared"]`` matches ``{run_1, shared}``
but not ``{shared}``.
``limit`` is the cap applied to matching scopes (-1 = unlimited, 0 = no new
observations, >0 = hard cap), mirroring ``max_observations_per_scope``.
"""
globs: tuple[str, ...]
limit: int
def _parse_scope_limit_rules(raw: Any) -> list[_ScopeLimitRule]:
"""Parse the raw ``observation_scope_limits`` config into ordered rules.
The config round-trips as JSON through env and the bank-config API, so this
is defensive: malformed entries are skipped rather than raising, and list
order is preserved (first match wins in :func:`_effective_scope_limit`).
"""
if not isinstance(raw, list):
return []
rules: list[_ScopeLimitRule] = []
for entry in raw:
if not isinstance(entry, dict):
continue
scope = entry.get("scope")
limit = entry.get("limit")
if not isinstance(scope, list) or not scope:
continue
if not all(isinstance(g, str) and g for g in scope):
continue
# bool is an int subclass — reject True/False masquerading as a limit.
if not isinstance(limit, int) or isinstance(limit, bool):
continue
rules.append(_ScopeLimitRule(globs=tuple(scope), limit=limit))
return rules
def _scope_matches_globs(globs: tuple[str, ...], tags: list[str]) -> bool:
"""Exact-cover match between a scope pattern and a concrete tag set.
True iff every tag is covered by at least one glob AND every glob covers at
least one tag (no uncovered tags, no vacuous globs). Untagged scopes never
match, so a scope limit never applies to untagged observations (consistent
with the ``and fact_tags`` guard at the call site). Matching is
case-sensitive (``fnmatchcase``) for deterministic cross-platform behaviour.
"""
tagset = set(tags)
if not tagset:
return False
if not all(any(fnmatchcase(t, g) for g in globs) for t in tagset):
return False
if not all(any(fnmatchcase(t, g) for t in tagset) for g in globs):
return False
return True
def _effective_scope_limit(config: Any, fact_tags: list[str]) -> int:
"""Resolve the observation cap for one concrete consolidation scope.
The first rule in ``observation_scope_limits`` whose pattern exact-covers
``fact_tags`` wins; otherwise falls back to the bank-wide
``max_observations_per_scope``. Wildcards live only here, matched against the
already-resolved concrete tags — the SQL count stays exact and indexed.
"""
if config is None:
return -1
for rule in _parse_scope_limit_rules(getattr(config, "observation_scope_limits", None)):
if _scope_matches_globs(rule.globs, fact_tags):
return rule.limit
return config.max_observations_per_scope
def _build_response_model(max_creates: int | None = None) -> type[_ConsolidationBatchResponse]:
"""Build a response model, optionally constraining max creates via JSON schema."""
if max_creates is None or max_creates < 0:
@@ -664,7 +546,6 @@ class ConsolidationPerfLog:
self.start_time = time.time()
self.lines: list[str] = []
self.timings: dict[str, float] = {}
self.timing_counts: dict[str, int] = {}
self.llm_calls: int = 0
self.total_obs_in_context: int = 0
self.total_prompt_chars: int = 0
@@ -674,13 +555,11 @@ class ConsolidationPerfLog:
self.lines.append(message)
def record_timing(self, key: str, duration: float) -> None:
"""Record a timing measurement.
Tracks both total seconds and call count so the summary can
distinguish one slow call from many fast calls in aggregate.
"""
self.timings[key] = self.timings.get(key, 0.0) + duration
self.timing_counts[key] = self.timing_counts.get(key, 0) + 1
"""Record a timing measurement."""
if key in self.timings:
self.timings[key] += duration
else:
self.timings[key] = duration
def record_llm_call(self, obs_count: int, prompt_chars: int) -> None:
"""Record stats for a single LLM call."""
@@ -703,8 +582,6 @@ class ConsolidationPerfLog:
"""
for key, value in other.timings.items():
self.timings[key] = self.timings.get(key, 0.0) + value
for key, count in other.timing_counts.items():
self.timing_counts[key] = self.timing_counts.get(key, 0) + count
self.llm_calls += other.llm_calls
self.total_obs_in_context += other.total_obs_in_context
self.total_prompt_chars += other.total_prompt_chars
@@ -1305,22 +1182,16 @@ async def _run_consolidation_job(
f"{stats['skipped']} skipped)"
)
# Add timing breakdown. Each phase is recorded once per call, so the count
# disambiguates a single slow call from many fast calls — important for
# operators triaging "the recall phase took 15s" log lines, where the
# total is the sum of many serial sub-calls rather than one slow query.
def _fmt(key: str) -> str:
total = perf.timings[key]
count = perf.timing_counts.get(key, 0)
if count > 1:
avg_ms = total * 1000.0 / count
return f"{key}={total:.3f}s ({count} calls, avg={avg_ms:.0f}ms)"
return f"{key}={total:.3f}s"
# Add timing breakdown
timing_parts = []
for key in ("recall", "llm", "embedding", "db_write"):
if key in perf.timings:
timing_parts.append(_fmt(key))
if "recall" in perf.timings:
timing_parts.append(f"recall={perf.timings['recall']:.3f}s")
if "llm" in perf.timings:
timing_parts.append(f"llm={perf.timings['llm']:.3f}s")
if "embedding" in perf.timings:
timing_parts.append(f"embedding={perf.timings['embedding']:.3f}s")
if "db_write" in perf.timings:
timing_parts.append(f"db_write={perf.timings['db_write']:.3f}s")
if perf.llm_calls > 0:
timing_parts.append(f"avg_obs={perf.total_obs_in_context / perf.llm_calls:.1f}")
@@ -1532,15 +1403,11 @@ async def _process_memory_batch(
# All memories in the batch share the same tag set (enforced by batching)
fact_tags = memories[0].get("tags") or [] if memories else []
# 2b. Compute remaining observation slots for this scope (if limit configured).
# The cap is resolved per-scope: an observation_scope_limits rule may override
# the bank-wide max_observations_per_scope for scopes matching its tag pattern.
max_obs = _effective_scope_limit(config, fact_tags)
# 2b. Compute remaining observation slots for this scope (if limit configured)
max_obs = config.max_observations_per_scope if config is not None else -1
remaining_observation_slots: int | None = None
if max_obs >= 0 and fact_tags:
# max_obs == 0 means "no new observations": there are no slots regardless
# of the current count, so skip the count query for that case.
current_count = await _count_observations_for_scope(conn, bank_id, fact_tags) if max_obs > 0 else 0
if max_obs > 0 and fact_tags:
current_count = await _count_observations_for_scope(conn, bank_id, fact_tags)
remaining_observation_slots = max(max_obs - current_count, 0)
if remaining_observation_slots == 0:
logger.info(
@@ -1855,12 +1722,6 @@ async def _execute_update_action(
config = get_config()
search_vector_clause = (
f",\n search_vector = to_tsvector('{config.text_search_extension_native_language}'::regconfig, COALESCE($1, ''))"
if config.text_search_extension == "native"
else ""
)
t0 = time.time()
await conn.execute(
f"""
@@ -1873,7 +1734,7 @@ async def _execute_update_action(
updated_at = now(),
occurred_start = LEAST(occurred_start, COALESCE($6, occurred_start)),
occurred_end = GREATEST(occurred_end, COALESCE($7, occurred_end)),
mentioned_at = GREATEST(mentioned_at, COALESCE($8, mentioned_at)){search_vector_clause}
mentioned_at = GREATEST(mentioned_at, COALESCE($8, mentioned_at))
WHERE id = $5
""",
new_text,
@@ -2184,7 +2045,7 @@ async def _consolidate_batch_with_llm(
# Build capacity note for the prompt when observation limit is configured
observation_capacity_note: str | None = None
if remaining_observation_slots is not None and max_observations_per_scope >= 0:
if remaining_observation_slots is not None and max_observations_per_scope > 0:
if remaining_observation_slots == 0:
observation_capacity_note = (
f"OBSERVATION LIMIT REACHED ({max_observations_per_scope}/{max_observations_per_scope}). "
@@ -2349,20 +2210,16 @@ async def _create_observation_directly(
tokenize($3, 'llmlingua2')::bm25_catalog.bm25vector)
RETURNING id
"""
elif config.text_search_extension == "native":
# Native: search_vector is populated with to_tsvector() using the
# configured native language dictionary, matching the batch insert
# path in ops_postgresql.insert_facts_batch.
query = f"""
INSERT INTO {fq_table("memory_units")} (
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids,
tags, event_date, occurred_start, occurred_end, mentioned_at, search_vector
)
VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, $6, $7, $8, $9, $10,
to_tsvector('{config.text_search_extension_native_language}'::regconfig, COALESCE($3, '')))
RETURNING id
"""
else: # pg_textsearch, pgroonga, pg_search: indexes operate on base text columns directly
else: # native, pg_textsearch, pgroonga, or pg_search
# pg_textsearch / pgroonga / pg_search: indexes operate on base text
# columns directly, so the dummy search_vector column is left NULL.
# Native: the migration p4q5r6s7t8u9 dropped the GENERATED expression on
# search_vector to allow per-deployment language configuration; the
# batch insert path in ops_postgresql.insert_facts_batch now populates
# it via to_tsvector($lang, ...). This single-observation INSERT does
# not, so observations under the native backend currently land with
# NULL search_vector and are not BM25-searchable until reflected/
# re-ingested. Tracking a separate fix for that gap.
query = f"""
INSERT INTO {fq_table("memory_units")} (
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids,
@@ -27,21 +27,34 @@ from ..config import (
DEFAULT_RERANKER_LITELLM_MODEL,
DEFAULT_RERANKER_LITELLM_SDK_MODEL,
DEFAULT_RERANKER_LOCAL_BATCH_SIZE,
DEFAULT_RERANKER_LOCAL_FORCE_CPU,
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT,
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_HTTP_TIMEOUT,
DEFAULT_RERANKER_TEI_MAX_CONCURRENT,
DEFAULT_RERANKER_ZEROENTROPY_MODEL,
DEFAULT_ZEROENTROPY_BASE_URL,
ENV_RERANKER_ALIBABA_API_KEY,
ENV_RERANKER_COHERE_API_KEY,
ENV_RERANKER_COHERE_MODEL,
ENV_RERANKER_FLASHRANK_CACHE_DIR,
ENV_RERANKER_FLASHRANK_CPU_MEM_ARENA,
ENV_RERANKER_FLASHRANK_MODEL,
ENV_RERANKER_GOOGLE_PROJECT_ID,
ENV_RERANKER_LOCAL_FORCE_CPU,
ENV_RERANKER_LOCAL_MAX_CONCURRENT,
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_HTTP_TIMEOUT,
ENV_RERANKER_TEI_MAX_CONCURRENT,
ENV_RERANKER_TEI_URL,
ENV_RERANKER_ZEROENTROPY_API_KEY,
)
@@ -212,7 +225,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
device = "cpu"
logger.info("Reranker: forcing CPU mode (HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU=1)")
else:
# Check for GPU (CUDA), Apple Silicon (MPS), or Intel XPU
# Check for GPU (CUDA) or Apple Silicon (MPS)
# Wrap in try-except to gracefully handle any device detection issues
# (e.g., in CI environments or when PyTorch is built without GPU support)
device = "cpu" # Default to CPU
@@ -220,13 +233,10 @@ class LocalSTCrossEncoder(CrossEncoderModel):
has_gpu = torch.cuda.is_available() or (
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
)
# Intel Arc XPU support — torch.xpu is available when the XPU build is loaded
if not has_gpu and hasattr(torch, "xpu"):
has_gpu = torch.xpu.is_available()
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS/XPU
device = None # Let sentence-transformers auto-detect GPU/MPS
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS/XPU, falling back to CPU: {e}")
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
# Patch transformers 5.x compatibility for models using XLM-RoBERTa
# (e.g., jina-reranker-v2-base-multilingual). transformers 5.x removed
@@ -293,6 +303,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
- bucket_batching: sort pairs by token length to reduce padding waste (36-54% speedup)
- batch_size: explicit batch size for predict() calls (MPS optimal: 32)
"""
import numpy as np
try:
if self.bucket_batching and len(pairs) > 1:
@@ -1668,7 +1679,7 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
return CohereCrossEncoder(
api_key=api_key,
model=config.reranker_openrouter_model,
base_url=config.reranker_openrouter_base_url,
base_url="https://openrouter.ai/api/v1/rerank",
timeout=config.reranker_openrouter_timeout,
)
elif provider == "flashrank":
@@ -19,6 +19,7 @@ and mirrors Django's ``DatabaseOperations`` architecture.
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
from uuid import UUID
from .base import DatabaseConnection
from .result import ResultRow
@@ -8,6 +8,8 @@ columns can't appear in GROUP BY).
import json
import uuid as uuid_mod
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from .base import DatabaseConnection
from .ops import DataAccessOps, TagListingParts
@@ -256,21 +258,13 @@ class OracleOps(DataAccessOps):
# Oracle doesn't support ON CONFLICT; rely on the PK and the
# IGNORE_ROW_ON_DUPKEY_INDEX hint to skip duplicates server-side.
# The hint name must match the PK constraint exactly.
#
# Sort to enforce a global lock-acquisition order on the
# (bank_id, unit_id) PK. Without this, two concurrent
# transactions inserting overlapping unit_id sets in different
# orders can deadlock on the unique-check row locks. Sorting
# gives every concurrent caller the same lock order, so
# conflicting inserts queue cleanly instead of cycling.
sorted_unit_ids = sorted(unit_ids)
await conn.executemany(
f"""
INSERT /*+ IGNORE_ROW_ON_DUPKEY_INDEX({table}, pk_graph_maintenance_queue) */
INTO {table} (bank_id, unit_id)
VALUES ($1, $2)
""",
[(bank_id, uid) for uid in sorted_unit_ids],
[(bank_id, uid) for uid in unit_ids],
)
async def claim_graph_maintenance_batch(
@@ -4,6 +4,11 @@ Uses unnest(), LATERAL, DISTINCT ON, and native array operations for
efficient batch operations.
"""
import json
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from .base import DatabaseConnection
from .ops import DataAccessOps, TagListingParts
from .result import ResultRow
@@ -348,15 +353,6 @@ class PostgreSQLOps(DataAccessOps):
) -> None:
if not unit_ids:
return
# Sort to enforce a global lock-acquisition order on the
# (bank_id, unit_id) unique-key. Without this, two concurrent
# transactions inserting overlapping unit_id sets in different
# orders can deadlock on the ON CONFLICT row locks — Postgres
# acquires a short-lived lock per row being checked, and cycle
# detection then aborts one transaction. Sorting gives every
# concurrent caller the same lock order, so conflicting inserts
# queue cleanly instead of cycling.
sorted_unit_ids = sorted(unit_ids)
await conn.execute(
f"""
INSERT INTO {table} (bank_id, unit_id)
@@ -364,7 +360,7 @@ class PostgreSQLOps(DataAccessOps):
ON CONFLICT (bank_id, unit_id) DO NOTHING
""",
bank_id,
sorted_unit_ids,
unit_ids,
)
async def claim_graph_maintenance_batch(
@@ -624,6 +620,7 @@ class PostgreSQLOps(DataAccessOps):
per_entity_limit: int,
) -> tuple[list[ResultRow], list[ResultRow], list[ResultRow]]:
# v0.5.6 array ops: unnest, &&, COUNT(DISTINCT) on source_memory_ids.
from ..schema import fq_table
entity_rows = await conn.fetch(
f"""
@@ -106,15 +106,6 @@ SCHEMAS_WITH_PENDING_WORK = OptionalRoutine(
deployment.
* Should be cheap and idempotent — called every poll cycle (~30s).
The poller trusts the result wholesale: any schema the routine does
not return is treated as having no work this cycle. It does NOT
second-guess omissions with a per-schema scan — that would re-run the
exact queries this routine exists to avoid. Consequently the routine
is *only* appropriate for multi-tenant deployments. Single-schema
(default/public only) installs should NOT create it: the per-schema
fallback below is a single cheap EXISTS check that covers ``public``
correctly and cannot starve.
Fallback when the routine is absent: per-schema ``EXISTS`` queries
from Python (~4ms per schema). The server-side path is a single-
round-trip optimisation worth ~200ms in deployments with thousands
@@ -26,8 +26,11 @@ from ..config import (
DEFAULT_EMBEDDINGS_GEMINI_MODEL,
DEFAULT_EMBEDDINGS_LITELLM_MODEL,
DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL,
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU,
DEFAULT_EMBEDDINGS_LOCAL_MODEL,
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
DEFAULT_EMBEDDINGS_OPENAI_MODEL,
DEFAULT_EMBEDDINGS_PROVIDER,
DEFAULT_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE,
DEFAULT_EMBEDDINGS_ZEROENTROPY_DIMENSIONS,
DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT,
@@ -37,6 +40,13 @@ from ..config import (
DEFAULT_ZEROENTROPY_BASE_URL,
ENV_EMBEDDINGS_COHERE_API_KEY,
ENV_EMBEDDINGS_GEMINI_API_KEY,
ENV_EMBEDDINGS_LOCAL_FORCE_CPU,
ENV_EMBEDDINGS_LOCAL_MODEL,
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
ENV_EMBEDDINGS_ONNX_DIMENSIONS,
ENV_EMBEDDINGS_ONNX_MODEL_ID,
ENV_EMBEDDINGS_ONNX_MODEL_PATH,
ENV_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH,
ENV_EMBEDDINGS_OPENAI_API_KEY,
ENV_EMBEDDINGS_OPENAI_BASE_URL,
ENV_EMBEDDINGS_OPENAI_MODEL,
@@ -47,7 +57,6 @@ from ..config import (
ENV_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT,
ENV_LLM_API_KEY,
)
from .bank_attribution import apply_bank_attribution
logger = logging.getLogger(__name__)
@@ -190,7 +199,7 @@ class LocalSTEmbeddings(Embeddings):
device = "cpu"
logger.info("Embeddings: forcing CPU mode")
else:
# Check for GPU (CUDA), Apple Silicon (MPS), or Intel XPU
# Check for GPU (CUDA) or Apple Silicon (MPS)
# Wrap in try-except to gracefully handle any device detection issues
# (e.g., in CI environments or when PyTorch is built without GPU support)
device = "cpu" # Default to CPU
@@ -198,13 +207,10 @@ class LocalSTEmbeddings(Embeddings):
has_gpu = torch.cuda.is_available() or (
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
)
# Intel Arc XPU support — torch.xpu is available when the XPU build is loaded
if not has_gpu and hasattr(torch, "xpu"):
has_gpu = torch.xpu.is_available()
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS/XPU
device = None # Let sentence-transformers auto-detect GPU/MPS
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS/XPU, falling back to CPU: {e}")
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
# Suppress verbose transformers warnings during model loading
# This suppresses the "UNEXPECTED" warnings from BertModel which are harmless
@@ -699,7 +705,6 @@ class OpenAIEmbeddings(Embeddings):
}
if self.dimensions is not None:
request["dimensions"] = self.dimensions
apply_bank_attribution(request)
response = self._client.embeddings.create(**request)
@@ -712,8 +717,7 @@ class OpenAIEmbeddings(Embeddings):
class CodexOAuthEmbeddings(OpenAIEmbeddings):
"""
OpenAI embeddings using the Codex/ChatGPT OAuth token from the Codex
``auth.json`` (``$CODEX_HOME/auth.json``, or ``~/.codex/auth.json`` when unset).
OpenAI embeddings using the Codex/ChatGPT OAuth token from ``~/.codex/auth.json``.
Codex OAuth is an LLM-provider auth path in Hindsight, but the same bearer token
can also authenticate against the standard OpenAI embeddings endpoint. This keeps
@@ -1343,21 +1347,6 @@ class LiteLLMSDKEmbeddings(Embeddings):
return all_embeddings
# Gemini Embedding 2+ multimodal models return a SINGLE aggregated embedding
# for a multi-input request instead of one vector per input (see
# https://ai.google.dev/gemini-api/docs/embeddings#embedding-aggregation). For
# these models we must embed one input per call to preserve the 1:1 input→vector
# alignment the rest of the pipeline relies on. The marker matches preview and GA
# names (e.g. "gemini-embedding-2-preview", "gemini-embedding-2"), with or
# without a "google/" or "models/" prefix.
_GEMINI_AGGREGATING_MODEL_MARKER = "gemini-embedding-2"
def _gemini_model_aggregates_inputs(model: str) -> bool:
"""Whether the model aggregates a multi-input request into one embedding."""
return _GEMINI_AGGREGATING_MODEL_MARKER in model.lower()
class GeminiEmbeddings(Embeddings):
"""
Google embeddings via the google.genai SDK.
@@ -1367,10 +1356,6 @@ class GeminiEmbeddings(Embeddings):
2. Vertex AI with service account or Application Default Credentials (ADC)
Uses the embed_content API: client.models.embed_content(model, contents)
Gemini Embedding 2+ multimodal models aggregate a multi-input request into a
single embedding, so for those the batch size is forced to 1 (one input per
call) to keep one vector per input.
"""
def __init__(
@@ -1525,13 +1510,9 @@ class GeminiEmbeddings(Embeddings):
all_embeddings = []
# Gemini Embedding 2+ multimodal models return one aggregated vector for a
# multi-input request, so embed one input per call to keep 1:1 alignment.
batch_size = 1 if _gemini_model_aggregates_inputs(self.model) else self.batch_size
# Process in batches
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
for i in range(0, len(texts), self.batch_size):
batch = texts[i : i + self.batch_size]
embed_kwargs = {"model": self.model, "contents": batch}
if self._embed_config is not None:
@@ -1539,13 +1520,7 @@ class GeminiEmbeddings(Embeddings):
result = self._client.models.embed_content(**embed_kwargs)
embeddings = result.embeddings or []
if len(embeddings) != len(batch):
raise RuntimeError(
f"Gemini embeddings backend returned {len(embeddings)} vectors for "
f"{len(batch)} input texts (model {self.model}); expected exact 1:1 alignment"
)
all_embeddings.extend([emb.values for emb in embeddings])
all_embeddings.extend([emb.values for emb in result.embeddings])
# L2-normalize when output_dimensionality is set — Gemini only returns
# normalized vectors at full 3072 dims; truncated dims need re-normalization
@@ -1638,20 +1613,6 @@ def create_embeddings_from_env() -> Embeddings:
batch_size=config.embeddings_openai_batch_size,
dimensions=config.embeddings_openai_dimensions,
)
elif provider == "requesty":
api_key = config.embeddings_requesty_api_key
if not api_key:
raise ValueError(
"HINDSIGHT_API_EMBEDDINGS_REQUESTY_API_KEY, HINDSIGHT_API_REQUESTY_API_KEY, "
f"or {ENV_LLM_API_KEY} is required when {ENV_EMBEDDINGS_PROVIDER} is 'requesty'"
)
return OpenAIEmbeddings(
api_key=api_key,
model=config.embeddings_requesty_model,
base_url="https://router.requesty.ai/v1",
batch_size=config.embeddings_openai_batch_size,
dimensions=config.embeddings_openai_dimensions,
)
elif provider == "zeroentropy":
api_key = config.embeddings_zeroentropy_api_key
if not api_key:
@@ -1715,6 +1676,6 @@ def create_embeddings_from_env() -> Embeddings:
else:
raise ValueError(
f"Unknown embeddings provider: {provider}. "
f"Supported: 'local', 'onnx', 'tei', 'openai', 'openai-codex', 'openrouter', 'requesty', 'cohere', 'google', "
f"Supported: 'local', 'onnx', 'tei', 'openai', 'openai-codex', 'openrouter', 'cohere', 'google', "
f"'zeroentropy', 'litellm', 'litellm-sdk'"
)
@@ -782,6 +782,239 @@ class EntityResolver:
return entity_ids
async def resolve_entity(
self,
bank_id: str,
entity_text: str,
context: str,
nearby_entities: list[dict],
unit_event_date,
) -> str:
"""
Resolve an entity to a canonical entity ID.
Args:
bank_id: bank ID (entities are scoped to agents)
entity_text: Entity text ("Alice", "Google", etc.)
context: Context where entity appears
nearby_entities: Other entities in the same unit
unit_event_date: When this unit was created
Returns:
Entity ID (creates new entity if needed)
"""
async with acquire_with_retry(self.pool) as conn:
# Find candidate entities with similar name
candidates = await conn.fetch(
f"""
SELECT id, canonical_name, metadata, last_seen
FROM {fq_table("entities")}
WHERE bank_id = $1
AND (
canonical_name ILIKE $2
OR canonical_name ILIKE $3
OR $2 ILIKE canonical_name || '%%'
)
ORDER BY mention_count DESC
""",
bank_id,
entity_text,
f"%{entity_text}%",
)
if not candidates:
# New entity - create it
return await self._create_entity(conn, bank_id, entity_text, unit_event_date)
# Score candidates based on:
# 1. Name similarity
# 2. Context overlap (TODO: could use embeddings)
# 3. Co-occurring entities
# 4. Temporal proximity
best_candidate = None
best_score = 0.0
best_name_similarity = 0.0
nearby_entity_set = {e["text"].lower() for e in nearby_entities if e["text"] != entity_text}
for row in candidates:
candidate_id = row["id"]
canonical_name = row["canonical_name"]
metadata = row["metadata"]
last_seen = row["last_seen"]
score = 0.0
# 1. Name similarity (0-1)
name_similarity = SequenceMatcher(None, entity_text.lower(), canonical_name.lower()).ratio()
score += name_similarity * 0.5
# 2. Co-occurring entities (0-0.5)
# Get entities that co-occurred with this candidate before
# Use the materialized co-occurrence cache for fast lookup
co_entity_rows = await conn.fetch(
f"""
SELECT e.canonical_name, ec.cooccurrence_count
FROM {fq_table("entity_cooccurrences")} ec
JOIN {fq_table("entities")} e ON (
CASE
WHEN ec.entity_id_1 = $1 THEN ec.entity_id_2
WHEN ec.entity_id_2 = $1 THEN ec.entity_id_1
END = e.id
)
WHERE ec.entity_id_1 = $1 OR ec.entity_id_2 = $1
""",
candidate_id,
)
co_entities = {r["canonical_name"].lower() for r in co_entity_rows}
# Check overlap with nearby entities
overlap = len(nearby_entity_set & co_entities)
if nearby_entity_set:
co_entity_score = overlap / len(nearby_entity_set)
score += co_entity_score * 0.3
# 3. Temporal proximity (0-0.2)
if last_seen:
# Normalize both to UTC-aware to avoid naive/aware mismatch
# (Oracle returns naive datetimes from fromisoformat)
_evt = unit_event_date if unit_event_date.tzinfo else unit_event_date.replace(tzinfo=UTC)
_seen = last_seen if last_seen.tzinfo else last_seen.replace(tzinfo=UTC)
days_diff = abs((_evt - _seen).total_seconds() / 86400)
if days_diff < 7: # Within a week
temporal_score = max(0, 1.0 - (days_diff / 7))
score += temporal_score * 0.2
if score > best_score:
best_score = score
best_candidate = candidate_id
best_name_similarity = name_similarity
# Threshold for considering it the same entity
threshold = 0.6
if best_score > threshold:
# Update entity
await conn.execute(
f"""
UPDATE {fq_table("entities")}
SET mention_count = mention_count + 1,
last_seen = $1
WHERE id = $2
""",
unit_event_date,
best_candidate,
)
return best_candidate
else:
# Not confident - create new entity
return await self._create_entity(conn, bank_id, entity_text, unit_event_date)
async def _create_entity(
self,
conn,
bank_id: str,
entity_text: str,
event_date,
) -> str:
"""
Create a new entity or get existing one if it already exists.
Uses INSERT ... ON CONFLICT to handle race conditions where
two concurrent transactions try to create the same entity.
Args:
conn: Database connection
bank_id: bank ID
entity_text: Entity text
event_date: When first seen
Returns:
Entity ID
"""
entity_id = await conn.fetchval(
f"""
INSERT INTO {fq_table("entities")} (bank_id, canonical_name, first_seen, last_seen, mention_count)
VALUES ($1, $2, COALESCE($3, now()), COALESCE($4, now()), 1)
ON CONFLICT (bank_id, LOWER(canonical_name))
DO UPDATE SET
mention_count = {fq_table("entities")}.mention_count + 1,
last_seen = EXCLUDED.last_seen
RETURNING id
""",
bank_id,
entity_text,
event_date,
event_date,
)
return entity_id
async def link_unit_to_entity(self, unit_id: str, entity_id: str):
"""
Link a memory unit to an entity.
Also updates co-occurrence cache with other entities in the same unit.
Args:
unit_id: Memory unit ID
entity_id: Entity ID
"""
async with acquire_with_retry(self.pool) as conn:
# Insert unit-entity link
await conn.execute(
f"""
INSERT INTO {fq_table("unit_entities")} (unit_id, entity_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
""",
unit_id,
entity_id,
)
# Update co-occurrence cache: find other entities in this unit
rows = await conn.fetch(
f"""
SELECT entity_id
FROM {fq_table("unit_entities")}
WHERE unit_id = $1 AND entity_id != $2
""",
unit_id,
entity_id,
)
other_entities = [row["entity_id"] for row in rows]
# Update co-occurrences for each pair
for other_entity_id in other_entities:
await self._update_cooccurrence(conn, entity_id, other_entity_id)
async def _update_cooccurrence(self, conn, entity_id_1: str, entity_id_2: str):
"""
Update the co-occurrence cache for two entities.
Uses CHECK constraint ordering (entity_id_1 < entity_id_2) to avoid duplicates.
Args:
conn: Database connection
entity_id_1: First entity ID
entity_id_2: Second entity ID
"""
# Ensure consistent ordering (smaller UUID first)
if entity_id_1 > entity_id_2:
entity_id_1, entity_id_2 = entity_id_2, entity_id_1
await conn.execute(
f"""
INSERT INTO {fq_table("entity_cooccurrences")} (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred)
VALUES ($1, $2, 1, NOW())
ON CONFLICT (entity_id_1, entity_id_2)
DO UPDATE SET
cooccurrence_count = {fq_table("entity_cooccurrences")}.cooccurrence_count + 1,
last_cooccurred = NOW()
""",
entity_id_1,
entity_id_2,
)
async def link_units_to_entities_batch(
self,
unit_entity_pairs: list[tuple[str, str]] | list[tuple[str, str, datetime | None]],
@@ -10,7 +10,7 @@ from datetime import datetime
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from hindsight_api.engine.memory_engine import BankLlmHealthInfo, Budget
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.response_models import RecallResult, ReflectResult
from hindsight_api.engine.search.tags import TagsMatch
from hindsight_api.models import RequestContext
@@ -449,7 +449,6 @@ class MemoryEngineInterface(ABC):
bank_id: str,
*,
request_context: "RequestContext",
force_refresh: bool = False,
) -> dict[str, Any]:
"""
Get statistics about memory nodes and links for a bank.
@@ -457,8 +456,6 @@ class MemoryEngineInterface(ABC):
Args:
bank_id: The memory bank ID.
request_context: Request context for authentication.
force_refresh: Bypass the cached value and recompute (also refreshes
the cache for subsequent callers).
Returns:
Dict with node_counts, link_counts, link_counts_by_fact_type
@@ -486,20 +483,6 @@ class MemoryEngineInterface(ABC):
"""
...
@abstractmethod
async def check_bank_llm(
self,
bank_id: str,
*,
request_context: "RequestContext",
) -> "BankLlmHealthInfo":
"""
Probe the LLM consolidation would use for this bank. Deliberate connectivity
test (one real minimal call); never returns the API key. See
MemoryEngine.check_bank_llm.
"""
...
@abstractmethod
async def get_entity(
self,
@@ -6,10 +6,9 @@ enabling support for multiple LLM backends (OpenAI, Anthropic, Gemini, Codex, et
"""
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Any
from .response_models import LLMToolCallResult
from .response_models import LLMToolCallResult, TokenUsage
class LLMInterface(ABC):
@@ -253,11 +252,3 @@ class OutputTooLongError(Exception):
"""
pass
class ProviderRateLimitResetError(Exception):
"""Raised when an upstream provider says quota will reopen at a known time."""
def __init__(self, retry_at: datetime, message: str = "") -> None:
self.retry_at = retry_at
super().__init__(message)
@@ -76,51 +76,6 @@ _request_ctx: ContextVar[dict[str, Any] | None] = ContextVar("hindsight_llm_requ
_call_metadata_ctx: ContextVar[dict[str, Any] | None] = ContextVar("hindsight_llm_call_metadata_ctx", default=None)
@dataclass
class LLMResponseUsage:
"""Provider-reported token usage for the in-flight LLM call.
Stashed by provider implementations as soon as a response is received —
*before* local JSON parsing / schema validation, which may still fail. The
wrapper reads it to attach real token counts to an error trace when the
provider call itself succeeded but the structured output couldn't be parsed
or validated (providers charge for those tokens regardless). See #2387.
"""
input_tokens: int = 0
output_tokens: int = 0
cached_tokens: int = 0
# Per-call provider usage, set by providers right after a response is received.
_response_usage_ctx: ContextVar[LLMResponseUsage | None] = ContextVar("hindsight_llm_response_usage_ctx", default=None)
def set_response_usage(usage: LLMResponseUsage | None) -> Token:
"""Bind provider-reported usage for the current call. Returns a reset token."""
return _response_usage_ctx.set(usage)
def stash_response_usage(usage: LLMResponseUsage | None) -> None:
"""Record provider-reported usage so an error trace can attach it later.
Called by provider implementations once a response (with usage) is in hand,
before parsing/validation that may raise. Overwrites any prior value from an
earlier retry attempt so the last attempt's usage wins.
"""
_response_usage_ctx.set(usage)
def reset_response_usage(token: Token) -> None:
"""Unwind a binding made by :func:`set_response_usage`."""
_response_usage_ctx.reset(token)
def current_response_usage() -> LLMResponseUsage | None:
"""Return the active call's provider-reported usage, or None."""
return _response_usage_ctx.get()
def set_trace_context(ctx: LLMTraceContext | None) -> Token:
"""Bind trace attribution to the current context. Returns a reset token."""
return _trace_ctx.set(ctx)
@@ -10,10 +10,15 @@ import re
import time
import uuid
from contextlib import AsyncExitStack
from typing import TYPE_CHECKING, Any
from pathlib import Path
from typing import Any
import httpx
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
# Vertex AI imports (conditional - for LLMProvider to pass credentials to GeminiLLM)
try:
import google.auth
from google.oauth2 import service_account
VERTEXAI_AVAILABLE = True
@@ -22,14 +27,16 @@ except ImportError:
from ..config import (
DEFAULT_LLM_MAX_CONCURRENT,
DEFAULT_LLM_TIMEOUT,
ENV_CONSOLIDATION_LLM_MAX_CONCURRENT,
ENV_LLM_GROQ_SERVICE_TIER,
ENV_LLM_MAX_CONCURRENT,
ENV_LLM_TIMEOUT,
ENV_REFLECT_LLM_MAX_CONCURRENT,
ENV_RETAIN_LLM_MAX_CONCURRENT,
)
if TYPE_CHECKING:
from .response_models import LLMToolCallResult
from ..metrics import get_metrics_collector
from .response_models import TokenUsage
# Seed applied to every Groq request for deterministic behavior.
DEFAULT_LLM_SEED = 4242
@@ -225,7 +232,6 @@ _PROVIDERS_WITHOUT_API_KEY = frozenset(
"litellm",
"litellmrouter",
"bedrock",
"nous",
}
)
@@ -243,7 +249,6 @@ def create_llm_provider(
reasoning_effort: str,
groq_service_tier: str | None = None,
openai_service_tier: str | None = None,
bedrock_service_tier: str | None = None,
extra_body: dict[str, Any] | None = None,
default_headers: dict[str, str] | None = None,
vertexai_project_id: str | None = None,
@@ -252,8 +257,6 @@ def create_llm_provider(
gemini_safety_settings: list | None = None,
prompt_cache_enabled: bool = False,
litellmrouter_config: dict[str, Any] | None = None,
gemini_service_tier: str | None = None,
timeout: float | None = None,
) -> Any: # Returns LLMInterface
"""
Factory function to create the appropriate LLM provider implementation.
@@ -266,31 +269,22 @@ def create_llm_provider(
reasoning_effort: Reasoning effort level for supported providers.
groq_service_tier: Groq service tier (for Groq provider) - "on_demand", "flex", or "auto".
openai_service_tier: OpenAI service tier (for OpenAI provider) - None (default) or "flex" (50% cheaper).
bedrock_service_tier: Bedrock service tier (for Bedrock provider) - None (default), "flex", "priority", or "reserved".
gemini_service_tier: Gemini service tier (for Gemini provider) - None (default) or "flex" (50% cheaper).
extra_body: Extra request-body params merged into the provider's native
call. Threaded into OpenAI-compatible, Fireworks, Anthropic, Gemini/
VertexAI and LiteLLM providers (each merges them in its own parameter
space). Keys must use each provider's native names (e.g. ``max_tokens``
for OpenAI/Anthropic vs ``max_output_tokens`` for Gemini).
default_headers: Custom headers passed to provider SDK clients (used by operators
routing through proxies / request-tracing middleware). Wired into the Anthropic
provider (SDK ``default_headers``) and the LiteLLM-backed providers — ``litellm``,
``litellmrouter`` and ``bedrock`` — as the LiteLLM ``extra_headers`` completion
kwarg; other providers may opt in as needed.
default_headers: Custom headers passed as ``default_headers`` to provider SDK clients
(used by operators routing through proxies / request-tracing middleware). Currently
wired into the Anthropic provider; other providers may opt in as needed.
vertexai_project_id: Vertex AI project ID (for VertexAI provider).
vertexai_region: Vertex AI region (for VertexAI provider).
vertexai_credentials: Vertex AI credentials object (for VertexAI provider).
timeout: Per-request LLM timeout in seconds (resolved by the caller from the
per-operation/global config). Threaded into the providers that honour a
configurable request timeout (LiteLLM, LiteLLM Router, OpenAI-compatible,
Nous). ``None`` lets each provider fall back to its own default
(``HINDSIGHT_API_LLM_TIMEOUT`` / ``DEFAULT_LLM_TIMEOUT`` for those four;
Anthropic and Gemini keep their provider-specific defaults).
Returns:
LLMInterface implementation for the specified provider.
"""
from .llm_interface import LLMInterface
from .providers import (
AnthropicLLM,
ClaudeCodeLLM,
@@ -306,12 +300,6 @@ def create_llm_provider(
)
provider_lower = provider.lower()
if provider_lower == "gemini":
from ..config import parse_gemini_service_tier
gemini_service_tier = parse_gemini_service_tier(gemini_service_tier)
else:
gemini_service_tier = None
if provider_lower == "openai-codex":
return CodexLLM(
@@ -360,7 +348,6 @@ def create_llm_provider(
vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials,
gemini_safety_settings=gemini_safety_settings,
gemini_service_tier=gemini_service_tier,
prompt_cache_enabled=prompt_cache_enabled,
extra_body=extra_body,
)
@@ -384,8 +371,6 @@ def create_llm_provider(
model=model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
default_headers=default_headers,
timeout=timeout,
)
elif provider_lower == "litellmrouter":
@@ -404,8 +389,6 @@ def create_llm_provider(
config=litellmrouter_config,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
default_headers=default_headers,
timeout=timeout,
)
elif provider_lower == "bedrock":
@@ -418,9 +401,6 @@ def create_llm_provider(
model=bedrock_model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
default_headers=default_headers,
bedrock_service_tier=bedrock_service_tier,
timeout=timeout,
)
elif provider_lower == "llamacpp":
@@ -454,22 +434,6 @@ def create_llm_provider(
extra_body=extra_body,
)
elif provider_lower == "nous":
# Nous Portal is OpenAI-compatible on the wire; NousLLM adds rotating
# inference:invoke JWT auth read natively from ~/.hermes/auth.json
# (no static api_key, no hermes_cli dependency — same shape as Codex).
from hindsight_api.engine.providers.nous_llm import NousLLM
return NousLLM(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
extra_body=extra_body,
timeout=timeout,
)
elif provider_lower in (
"openai",
"groq",
@@ -480,10 +444,8 @@ def create_llm_provider(
"deepseek",
"volcano",
"openrouter",
"requesty",
"zai",
"opencode-go",
"atlas",
):
return OpenAICompatibleLLM(
provider=provider,
@@ -494,7 +456,6 @@ def create_llm_provider(
groq_service_tier=groq_service_tier,
openai_service_tier=openai_service_tier,
extra_body=extra_body,
timeout=timeout,
)
else:
@@ -517,20 +478,11 @@ class LLMProvider:
reasoning_effort: str = "low",
groq_service_tier: str | None = None,
openai_service_tier: str | None = None,
bedrock_service_tier: str | None = None,
gemini_safety_settings: list | None = None,
prompt_cache_enabled: bool = False,
extra_body: dict[str, Any] | None = None,
default_headers: dict[str, str] | None = None,
litellmrouter_config: dict[str, Any] | None = None,
gemini_service_tier: str | None = None,
vertexai_project_id: str | None = None,
vertexai_region: str | None = None,
vertexai_service_account_key: str | None = None,
timeout: float | None = None,
max_retries: int | None = None,
initial_backoff: float | None = None,
max_backoff: float | None = None,
):
"""
Initialize LLM provider.
@@ -543,61 +495,28 @@ class LLMProvider:
reasoning_effort: Reasoning effort level for supported providers.
groq_service_tier: Groq service tier ("on_demand", "flex", "auto") - from config.
openai_service_tier: OpenAI service tier (None or "flex") - from config.
bedrock_service_tier: Bedrock service tier (None, "flex", "priority", "reserved") - from config.
gemini_service_tier: Gemini service tier (None or "flex") - from config.
gemini_safety_settings: Safety settings for Gemini/VertexAI providers.
extra_body: Extra request-body params merged into the provider's native call
(OpenAI-compatible, Fireworks, Anthropic, Gemini/VertexAI, LiteLLM).
default_headers: Custom headers passed as ``default_headers`` to provider SDK clients.
Used by operators routing through proxies / request-tracing middleware.
Used by operators routing through proxies / request-tracing middleware. Falls
back to ``HindsightConfig.llm_default_headers`` (env: ``HINDSIGHT_API_LLM_DEFAULT_HEADERS``)
when ``None``.
litellmrouter_config: Provider-specific config for ``provider="litellmrouter"``.
JSON object passed verbatim to ``litellm.Router(**config)`` — see
https://docs.litellm.ai/docs/routing. Ignored unless ``provider == "litellmrouter"``.
vertexai_project_id: Vertex AI project ID for ``provider="vertexai"`` (required for
that provider).
vertexai_region: Vertex AI region for ``provider="vertexai"`` (defaults to
``"us-central1"`` when ``None``).
vertexai_service_account_key: Path to a Vertex AI service-account key file for
``provider="vertexai"`` (uses ADC when ``None``).
timeout: Per-request LLM timeout in seconds. Resolved by the caller from the
per-operation/global config (``retain_llm_timeout`` falling back to
``llm_timeout``, etc.). ``None`` lets each provider apply its own default.
max_retries: Default retry-attempt budget for ``call`` / ``call_with_tools``
when the per-call argument is omitted. Resolved by the caller from the
per-operation/global config (``reflect_llm_max_retries`` falling back to
``llm_max_retries``, etc.). ``None`` keeps each method's own fallback.
initial_backoff: Default initial retry backoff (seconds), same resolution as
``max_retries``. ``None`` keeps each method's own fallback.
max_backoff: Default maximum retry backoff (seconds), same resolution as
``max_retries``. ``None`` keeps each method's own fallback.
This constructor uses every argument as passed and does not read global
``HindsightConfig``: resolving the server-level default for a ``None`` argument is the
caller's responsibility (see ``MemoryEngine``'s per-op builds, ``_member_to_llm``, and
``LLMProvider.from_env``). Keeping it config-free makes a provider's effective settings a
pure function of its arguments — which is what lets each member of a multi-LLM chain be
configured independently.
When None and the provider is ``litellmrouter``, falls back to
``HindsightConfig.llm_litellmrouter_config``.
"""
self.provider = provider.lower()
self.api_key = api_key
self.base_url = base_url
self.model = model
self.reasoning_effort = reasoning_effort
# Per-request timeout (seconds). Used verbatim — the caller resolves the
# per-operation/global fallback. ``None`` defers to the provider default.
self.timeout = timeout
# Default retry policy for call()/call_with_tools(). The caller resolves the
# per-operation/global fallback; ``None`` keeps each method's own fallback so
# providers built without a resolved config (from_env, tests) are unchanged.
self.max_retries = max_retries
self.initial_backoff = initial_backoff
self.max_backoff = max_backoff
self.litellmrouter_config = litellmrouter_config
# Service tiers from hierarchical config (not env vars)
self.groq_service_tier = groq_service_tier
self.openai_service_tier = openai_service_tier
self.bedrock_service_tier = bedrock_service_tier
self.gemini_service_tier = gemini_service_tier
# Gemini safety settings (instance default; can be overridden per-request via context var)
self.gemini_safety_settings = gemini_safety_settings
# Gemini prompt caching: when True, retain extraction (and any future
@@ -608,9 +527,16 @@ class LLMProvider:
# Extra body params for OpenAI-compatible providers (e.g. chat_template_kwargs)
self.extra_body = extra_body
# Default headers passed to provider SDK clients (e.g. proxy auth, request tracing).
# Used verbatim — callers resolve the global fallback (see _member_to_llm /
# the per-op builds in MemoryEngine, and LLMProvider.from_env).
# Same pattern as ``gemini_safety_settings``: explicit override wins; otherwise read
# the static server-level default from ``HindsightConfig`` via ``_get_raw_config()``.
self.default_headers = default_headers
if self.default_headers is None:
from ..config import _get_raw_config
try:
self.default_headers = _get_raw_config().llm_default_headers
except Exception:
pass # Config may not be initialized in test environments
# Validate provider
valid_providers = [
@@ -634,12 +560,9 @@ class LLMProvider:
"bedrock",
"volcano",
"openrouter",
"requesty",
"zai",
"opencode-go",
"atlas",
"fireworks",
"nous",
]
if self.provider not in valid_providers:
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
@@ -660,31 +583,30 @@ class LLMProvider:
self.base_url = "https://api.deepseek.com"
elif self.provider == "openrouter":
self.base_url = "https://openrouter.ai/api/v1"
elif self.provider == "requesty":
self.base_url = "https://router.requesty.ai/v1"
elif self.provider == "zai":
self.base_url = "https://api.z.ai/api/coding/paas/v4"
elif self.provider == "opencode-go":
self.base_url = "https://opencode.ai/zen/go/v1"
elif self.provider == "atlas":
self.base_url = "https://api.atlascloud.ai/v1"
elif self.provider == "nous":
self.base_url = "https://inference-api.nousresearch.com/v1"
# Prepare Vertex AI config (if applicable). Values are used as passed; the
# caller resolves the global-config fallback (MemoryEngine builds /
# _member_to_llm / from_env). The region keeps a constant default here.
# Prepare Vertex AI config (if applicable)
vertexai_project_id = None
vertexai_region = None
vertexai_credentials = None
if self.provider == "vertexai":
from ..config import get_config
config = get_config()
vertexai_project_id = config.llm_vertexai_project_id
if not vertexai_project_id:
raise ValueError(
"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID is required for Vertex AI provider. "
"Set it to your GCP project ID."
)
vertexai_region = vertexai_region or "us-central1"
service_account_key = vertexai_service_account_key
vertexai_region = config.llm_vertexai_region or "us-central1"
service_account_key = config.llm_vertexai_service_account_key
# Load explicit service account credentials if provided
if service_account_key:
@@ -708,20 +630,45 @@ class LLMProvider:
f"model={self.model}, auth={'service_account' if service_account_key else 'ADC'}"
)
# Normalize the Gemini service tier (pure: maps/validates the passed value,
# no global config read). Non-Gemini providers never carry a tier. The
# server-level default is resolved by the caller, like the other fields.
if self.provider == "gemini":
from ..config import parse_gemini_service_tier
# For Gemini/VertexAI providers: read safety settings from global config if not explicitly provided
# Use _get_raw_config() to bypass StaticConfigProxy (which blocks configurable fields),
# since LLMProvider initialization legitimately needs the server-level default.
if self.provider in ("gemini", "vertexai") and self.gemini_safety_settings is None:
from ..config import _get_raw_config
self.gemini_service_tier = parse_gemini_service_tier(self.gemini_service_tier)
else:
self.gemini_service_tier = None
try:
raw_config = _get_raw_config()
self.gemini_safety_settings = raw_config.llm_gemini_safety_settings
except Exception:
pass # Config may not be initialized in test environments
# gemini_safety_settings / prompt_cache_enabled / litellmrouter_config are
# used as passed — the caller resolves the global-config fallback. Providers
# that don't support prompt caching ignore the flag.
# Prompt-prefix caching is a provider-agnostic toggle (default on): resolve
# it from the static server config for every provider when the caller didn't
# pass an explicit override. Providers that don't support caching ignore the
# value; only those that implement get_or_create_cached_prefix act on it.
if not self.prompt_cache_enabled:
from ..config import DEFAULT_LLM_PROMPT_CACHE_ENABLED, _get_raw_config
try:
raw_config = _get_raw_config()
self.prompt_cache_enabled = bool(
getattr(raw_config, "llm_prompt_cache_enabled", DEFAULT_LLM_PROMPT_CACHE_ENABLED)
)
except Exception:
pass # Config may not be initialized in test environments
# For litellmrouter: prefer an explicit chain from the caller (per-op
# construction in MemoryEngine threads the right chain through). If the caller
# didn't supply one, fall back to the global ``llm_litellmrouter_config`` so
# ad-hoc constructions (e.g. ``LLMProvider.from_env()``) keep working.
router_config: dict[str, Any] | None = self.litellmrouter_config
if self.provider == "litellmrouter" and router_config is None:
from ..config import _get_raw_config
try:
router_config = _get_raw_config().llm_litellmrouter_config
except Exception:
router_config = None
# Create provider implementation using factory
self._provider_impl = create_llm_provider(
@@ -732,8 +679,6 @@ class LLMProvider:
reasoning_effort=self.reasoning_effort,
groq_service_tier=self.groq_service_tier,
openai_service_tier=self.openai_service_tier,
bedrock_service_tier=self.bedrock_service_tier,
gemini_service_tier=self.gemini_service_tier,
extra_body=self.extra_body,
default_headers=self.default_headers,
vertexai_project_id=vertexai_project_id,
@@ -742,7 +687,6 @@ class LLMProvider:
gemini_safety_settings=self.gemini_safety_settings,
prompt_cache_enabled=self.prompt_cache_enabled,
litellmrouter_config=router_config,
timeout=self.timeout,
)
# Backward compatibility: Keep mock provider properties
@@ -799,9 +743,9 @@ class LLMProvider:
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "memory",
max_retries: int | None = None,
initial_backoff: float | None = None,
max_backoff: float | None = None,
max_retries: int = 10,
initial_backoff: float = 1.0,
max_backoff: float = 60.0,
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
@@ -816,12 +760,9 @@ class LLMProvider:
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts. ``None`` uses the provider's configured
default (per-operation/global ``llm_max_retries``), else 10.
initial_backoff: Initial backoff time in seconds. ``None`` uses the provider's
configured default (``llm_initial_backoff``), else 1.0.
max_backoff: Maximum backoff time in seconds. ``None`` uses the provider's
configured default (``llm_max_backoff``), else 60.0.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
skip_validation: Return raw JSON without Pydantic validation.
strict_schema: Per-call override requesting grammar-enforced (json_schema strict)
structured output instead of the soft json_object path. The server-level
@@ -846,20 +787,6 @@ class LLMProvider:
structured = "+structured" if response_format is not None else ""
set_stage(f"llm.{self.provider}.{scope}{structured}")
# Resolve the retry policy: explicit per-call arg wins, else the provider's
# configured per-operation/global default, else this method's own fallback.
max_retries = (
max_retries if max_retries is not None else (self.max_retries if self.max_retries is not None else 10)
)
initial_backoff = (
initial_backoff
if initial_backoff is not None
else (self.initial_backoff if self.initial_backoff is not None else 1.0)
)
max_backoff = (
max_backoff if max_backoff is not None else (self.max_backoff if self.max_backoff is not None else 60.0)
)
# Resolve strict-schema once, here, rather than in each provider: the
# per-call argument OR the server-level HINDSIGHT_API_LLM_STRICT_SCHEMA
# flag. Providers with a json_schema response_format (OpenAI-compatible,
@@ -876,13 +803,7 @@ class LLMProvider:
# The requested params are stashed in a contextvar (only what the caller
# actually set) so the recorder can attach them to either path.
from ..tracing import get_span_recorder
from .llm_trace import (
current_response_usage,
reset_request_context,
reset_response_usage,
set_request_context,
set_response_usage,
)
from .llm_trace import reset_request_context, set_request_context
call_start = time.monotonic()
request_token = set_request_context(
@@ -893,9 +814,6 @@ class LLMProvider:
response_format=response_format,
)
)
# Cleared per call; the provider stashes real usage once a response is in
# hand so the error path below can attach it if parsing/validation fails.
usage_token = set_response_usage(None)
try:
async with AsyncExitStack() as stack:
for sem in _semaphores_for_scope(scope):
@@ -923,19 +841,14 @@ class LLMProvider:
**cache_kwarg,
)
except Exception as e:
# The provider call may have succeeded (and incurred token
# cost) before local parsing/validation raised; attach the
# provider-reported usage to the error trace when available.
usage = current_response_usage()
get_span_recorder().record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=None,
input_tokens=usage.input_tokens if usage else 0,
output_tokens=usage.output_tokens if usage else 0,
cached_tokens=usage.cached_tokens if usage else 0,
input_tokens=0,
output_tokens=0,
duration=time.monotonic() - call_start,
error=e,
)
@@ -951,7 +864,6 @@ class LLMProvider:
self._mock_calls = self._provider_impl.get_mock_calls()
finally:
reset_request_context(request_token)
reset_response_usage(usage_token)
return result
@@ -962,9 +874,9 @@ class LLMProvider:
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int | None = None,
initial_backoff: float | None = None,
max_backoff: float | None = None,
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
cached_prefix: str | None = None,
) -> "LLMToolCallResult":
@@ -977,12 +889,9 @@ class LLMProvider:
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts. ``None`` uses the provider's configured
default (per-operation/global ``llm_max_retries``), else 5.
initial_backoff: Initial backoff time in seconds. ``None`` uses the provider's
configured default (``llm_initial_backoff``), else 1.0.
max_backoff: Maximum backoff time in seconds. ``None`` uses the provider's
configured default (``llm_max_backoff``), else 30.0.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or {"type": "function", "function": {"name": "..."}}
Returns:
@@ -992,29 +901,9 @@ class LLMProvider:
set_stage(f"llm.{self.provider}.{scope}+tools")
# Resolve the retry policy: explicit per-call arg wins, else the provider's
# configured per-operation/global default, else this method's own fallback.
max_retries = (
max_retries if max_retries is not None else (self.max_retries if self.max_retries is not None else 5)
)
initial_backoff = (
initial_backoff
if initial_backoff is not None
else (self.initial_backoff if self.initial_backoff is not None else 1.0)
)
max_backoff = (
max_backoff if max_backoff is not None else (self.max_backoff if self.max_backoff is not None else 30.0)
)
# Failures forwarded to the GenAI recorder; successes recorded by providers.
from ..tracing import get_span_recorder
from .llm_trace import (
current_response_usage,
reset_request_context,
reset_response_usage,
set_request_context,
set_response_usage,
)
from .llm_trace import reset_request_context, set_request_context
call_start = time.monotonic()
request_token = set_request_context(
@@ -1025,9 +914,6 @@ class LLMProvider:
tool_choice=tool_choice,
)
)
# Cleared per call; the provider stashes real usage once a response is in
# hand so the error path below can attach it if parsing/validation fails.
usage_token = set_response_usage(None)
try:
async with AsyncExitStack() as stack:
for sem in _semaphores_for_scope(scope):
@@ -1052,19 +938,14 @@ class LLMProvider:
**cache_kwarg,
)
except Exception as e:
# The provider call may have succeeded (and incurred token
# cost) before local parsing/validation raised; attach the
# provider-reported usage to the error trace when available.
usage = current_response_usage()
get_span_recorder().record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=None,
input_tokens=usage.input_tokens if usage else 0,
output_tokens=usage.output_tokens if usage else 0,
cached_tokens=usage.cached_tokens if usage else 0,
input_tokens=0,
output_tokens=0,
duration=time.monotonic() - call_start,
error=e,
)
@@ -1080,7 +961,6 @@ class LLMProvider:
self._mock_calls = self._provider_impl.get_mock_calls()
finally:
reset_request_context(request_token)
reset_response_usage(usage_token)
return result
@@ -1124,9 +1004,7 @@ class LLMProvider:
def _load_codex_auth(self) -> tuple[str, str]:
"""
Load OAuth credentials from the Codex ``auth.json``.
Honors ``CODEX_HOME`` (falling back to ``~/.codex``).
Load OAuth credentials from ~/.codex/auth.json.
Returns:
Tuple of (access_token, account_id).
@@ -1135,9 +1013,7 @@ class LLMProvider:
FileNotFoundError: If auth file doesn't exist.
ValueError: If auth file is invalid.
"""
from .providers.codex_auth import default_codex_auth_file
auth_file = default_codex_auth_file()
auth_file = Path.home() / ".codex" / "auth.json"
if not auth_file.exists():
raise FileNotFoundError(
@@ -1239,38 +1115,17 @@ class LLMProvider:
@classmethod
def from_env(cls) -> "LLMProvider":
"""Create provider from environment variables using config.py constants."""
# Read every field straight from the environment. The constructor no longer
# resolves global-config fallbacks, so this factory must supply them — and it
# does so without building the full HindsightConfig, keeping from_env() a
# lightweight env-only loader (see test_llm_provider_from_env_keeps_lightweight_loader).
from ..config import (
DEFAULT_LLM_GROQ_SERVICE_TIER,
DEFAULT_LLM_OPENAI_SERVICE_TIER,
DEFAULT_LLM_PROMPT_CACHE_ENABLED,
DEFAULT_LLM_PROVIDER,
DEFAULT_LLM_REASONING_EFFORT,
DEFAULT_LLM_TIMEOUT,
ENV_LLM_API_KEY,
ENV_LLM_BASE_URL,
ENV_LLM_BEDROCK_SERVICE_TIER,
ENV_LLM_DEFAULT_HEADERS,
ENV_LLM_EXTRA_BODY,
ENV_LLM_GEMINI_SAFETY_SETTINGS,
ENV_LLM_GEMINI_SERVICE_TIER,
ENV_LLM_GROQ_SERVICE_TIER,
ENV_LLM_LITELLMROUTER_CONFIG,
ENV_LLM_MODEL,
ENV_LLM_OPENAI_SERVICE_TIER,
ENV_LLM_PROMPT_CACHE_ENABLED,
ENV_LLM_PROVIDER,
ENV_LLM_REASONING_EFFORT,
ENV_LLM_TIMEOUT,
ENV_LLM_VERTEXAI_PROJECT_ID,
ENV_LLM_VERTEXAI_REGION,
ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY,
_get_default_model_for_provider,
_parse_llm_router_config,
parse_gemini_service_tier,
)
provider = os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER)
@@ -1287,14 +1142,6 @@ class LLMProvider:
model = os.getenv(ENV_LLM_MODEL) or _get_default_model_for_provider(provider)
extra_body = json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null"))
default_headers = json.loads(os.getenv(ENV_LLM_DEFAULT_HEADERS, "null"))
prompt_cache_enabled = os.getenv(
ENV_LLM_PROMPT_CACHE_ENABLED, str(DEFAULT_LLM_PROMPT_CACHE_ENABLED)
).lower() in (
"1",
"true",
"yes",
"on",
)
return cls(
provider=provider,
@@ -1304,21 +1151,6 @@ class LLMProvider:
reasoning_effort=os.getenv(ENV_LLM_REASONING_EFFORT, DEFAULT_LLM_REASONING_EFFORT),
extra_body=extra_body,
default_headers=default_headers,
groq_service_tier=os.getenv(ENV_LLM_GROQ_SERVICE_TIER, DEFAULT_LLM_GROQ_SERVICE_TIER),
openai_service_tier=os.getenv(ENV_LLM_OPENAI_SERVICE_TIER, DEFAULT_LLM_OPENAI_SERVICE_TIER),
bedrock_service_tier=os.getenv(ENV_LLM_BEDROCK_SERVICE_TIER) or None,
gemini_service_tier=(
parse_gemini_service_tier(os.getenv(ENV_LLM_GEMINI_SERVICE_TIER))
if provider.lower() == "gemini"
else None
),
gemini_safety_settings=json.loads(os.getenv(ENV_LLM_GEMINI_SAFETY_SETTINGS, "null")),
prompt_cache_enabled=prompt_cache_enabled,
litellmrouter_config=_parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG),
vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or None,
vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION) or None,
vertexai_service_account_key=os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY) or None,
timeout=float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT))),
)
@@ -11,12 +11,6 @@ from one place, so we don't spawn a separate ``asyncio`` task per concern:
consolidation operation failed terminally and left them with
``consolidated_at IS NULL AND consolidation_failed_at IS NULL`` and nothing to
re-trigger them.
- **Scheduled mental model refresh** (configurable check cadence, default 60s):
refresh mental models whose ``trigger.refresh_cron`` schedule is due, but only
when the model is stale (new memories in its scope since its last refresh), so
a scheduled tick never burns an LLM call to regenerate identical content. The
per-model schedule lives in the cron expression; this loop only decides when to
*check*.
The loop wakes on a short fixed tick and runs each job when its own
``last_run + interval`` is due (run-at-start, then on interval), so adding jobs
@@ -31,14 +25,12 @@ from __future__ import annotations
import asyncio
import logging
import time
from collections.abc import Coroutine
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from ..config import HindsightConfig, get_config
from ..models import RequestContext
from .db_utils import acquire_with_retry
from .schema import _is_oracle, fq_table
from .schema import _is_oracle
if TYPE_CHECKING:
from .memory_engine import MemoryEngine
@@ -99,8 +91,7 @@ class MaintenanceLoop:
reconcile_on = cfg.consolidation_reconcile_interval_seconds > 0
audit_on = cfg.audit_log_enabled and cfg.audit_log_retention_days > 0
llm_on = cfg.llm_trace_enabled and cfg.llm_trace_retention_days > 0
mm_refresh_on = cfg.mental_model_refresh_tick_seconds > 0
return reconcile_on or audit_on or llm_on or mm_refresh_on
return reconcile_on or audit_on or llm_on
# ── loop ───────────────────────────────────────────────────────────────
@@ -127,25 +118,10 @@ class MaintenanceLoop:
async def _tick(self) -> None:
cfg = get_config()
if self._is_due("retention", _RETENTION_INTERVAL_SECONDS):
await self._run_timed("retention", self._run_retention(cfg))
await self._run_retention(cfg)
interval = cfg.consolidation_reconcile_interval_seconds
if interval > 0 and self._is_due("reconcile", interval):
await self._run_timed("consolidation reconcile", self._run_reconcile())
mm_interval = cfg.mental_model_refresh_tick_seconds
if mm_interval > 0 and self._is_due("mm_refresh", mm_interval):
await self._run_timed("scheduled mental model refresh", self._run_scheduled_mm_refresh())
async def _run_timed(self, name: str, coro: Coroutine[Any, Any, None]) -> None:
"""Run a maintenance job and emit one timing line for it.
Each job keeps its own summary log (counts of work done); this adds a
single, uniform line per run so the cost of every sweep is observable.
"""
start = time.monotonic()
try:
await coro
finally:
logger.info(f"Maintenance: {name} took {time.monotonic() - start:.3f}s")
await self._run_reconcile()
# ── retention ──────────────────────────────────────────────────────────
@@ -236,112 +212,3 @@ class MaintenanceLoop:
f"Consolidation reconcile: scheduled {submitted} bank(s)"
+ (f", skipped {skipped_unknown} in unrecognized schema(s)" if skipped_unknown else "")
)
# ── scheduled mental model refresh ───────────────────────────────────────
async def _run_scheduled_mm_refresh(self) -> None:
"""Refresh mental models whose ``trigger.refresh_cron`` is due.
Discovery (the set of cron-scheduled models, minus any with an in-flight
refresh) is one cross-tenant round-trip via
``public.mental_models_with_cron()``. Cron *due-ness* is evaluated here in
Python a scheduled fire has elapsed when the most recent cron boundary at
or before now is later than ``last_refreshed_at`` because cron arithmetic
isn't expressible in plain SQL. Each due model is refreshed only when it is
actually stale, so a schedule that fires while nothing changed costs a
cheap staleness query, not an LLM call.
"""
engine = self._engine
try:
async with acquire_with_retry(engine._backend, max_retries=1) as conn:
rows = await conn.fetch(
"SELECT schema_name, bank_id, mental_model_id, refresh_cron, last_refreshed_at "
"FROM public.mental_models_with_cron()"
)
except Exception as e:
logger.warning(f"Scheduled mental model refresh discovery failed: {e}")
return
if not rows:
return
from croniter import croniter
now = datetime.now(timezone.utc)
due = []
for row in rows:
cron = row["refresh_cron"]
last = row["last_refreshed_at"]
try:
prev_fire = croniter(cron, now).get_prev(datetime)
except (ValueError, KeyError) as e:
logger.warning(
f"Scheduled mental model refresh: skipping invalid cron {cron!r} for "
f"{row['schema_name']}/{row['mental_model_id']}: {e}"
)
continue
if last is None or prev_fire > last:
due.append(row)
if not due:
return
# Only enqueue into schemas the worker actually polls (tenant discovery),
# otherwise the op would never be claimed. The tenant_id (when provided)
# lets config resolution honor tenant-level overrides.
try:
tenants = await engine._tenant_extension.list_tenants()
except Exception as e:
logger.warning(f"Scheduled mental model refresh tenant discovery failed: {e}")
return
tenant_by_schema = {t.schema: t for t in tenants}
default_schema = get_config().database_schema
from .memory_engine import _current_schema
submitted = 0
skipped_unknown = 0
skipped_fresh = 0
for row in due:
schema = row["schema_name"]
bank_id = row["bank_id"]
mm_id = row["mental_model_id"]
tenant = tenant_by_schema.get(schema)
if tenant is None and schema != default_schema:
skipped_unknown += 1
continue
tenant_id = tenant.tenant_id if tenant else None
token = _current_schema.set(schema)
try:
context = RequestContext(internal=True, tenant_id=tenant_id)
# Skip if nothing in the model's scope changed since its last
# refresh — a scheduled refresh must not regenerate identical
# content. compute_mental_model_is_stale needs the model's tags +
# trigger, which the discovery routine doesn't return, so re-read
# the row under the bank's schema context.
async with acquire_with_retry(engine._backend, max_retries=1) as conn:
mm_row = await conn.fetchrow(
f"SELECT id, tags, trigger, last_refreshed_at FROM {fq_table('mental_models')} "
"WHERE bank_id = $1 AND id = $2",
bank_id,
mm_id,
)
if mm_row is None:
continue
is_stale = await engine.compute_mental_model_is_stale(conn, bank_id, mm_row)
if not is_stale:
skipped_fresh += 1
continue
await engine.submit_async_refresh_mental_model(
bank_id=bank_id, mental_model_id=mm_id, request_context=context
)
submitted += 1
except Exception as e:
logger.warning(f"Scheduled mental model refresh failed for {mm_id} in {schema}: {e}")
finally:
_current_schema.reset(token)
if submitted or skipped_unknown or skipped_fresh:
logger.info(
f"Scheduled mental model refresh: scheduled {submitted} model(s)"
+ (f", {skipped_fresh} up-to-date" if skipped_fresh else "")
+ (f", skipped {skipped_unknown} in unrecognized schema(s)" if skipped_unknown else "")
)
File diff suppressed because it is too large Load Diff
@@ -1,204 +0,0 @@
"""Multi-LLM routing: failover and (weighted) round-robin across N providers.
``MultiLLMProvider`` wraps an ordered list of :class:`LLMProvider` members and a
:class:`~hindsight_api.config.LLMStrategyConfig`, exposing the same public surface
as a single ``LLMProvider`` so it drops into every existing call path (including
``with_config()`` / ``ConfiguredLLMProvider``).
Member 0 is the **primary** (the operation's unindexed/base LLM); members 1..N are
the indexed extras (``HINDSIGHT_API_<OP>LLM_<n>_*``). Each member keeps its own
internal retry budget, so we only advance to the next member after a member has
exhausted its retries and raised.
Strategies:
- ``failover``: try members in declared order ``[0..N]``.
- ``round-robin``: rotate the starting member per request (optionally weighted),
then fall through the remaining members on error.
Batch retain and any direct ``_provider_impl`` access operate on the **primary
member only** (via attribute passthrough) failover/round-robin apply to the
interactive ``call`` / ``call_with_tools`` paths.
"""
import logging
import threading
import uuid
from typing import TYPE_CHECKING, Any
from ..config import LLM_STRATEGY_FAILOVER, LLMStrategyConfig
from .llm_wrapper import LLMProvider, OutputTooLongError
if TYPE_CHECKING:
from .llm_wrapper import ConfiguredLLMProvider, LLMToolCallResult
logger = logging.getLogger(__name__)
def _should_failover(exc: BaseException) -> bool:
"""Whether ``exc`` from one member should trigger a try on the next member.
Generic ``Exception`` instances (network errors, provider 5xx, timeouts after
a member's own retries) fail over. ``OutputTooLongError`` is propagated — a
different provider won't fit an over-length output either. ``CancelledError``,
``KeyboardInterrupt`` and ``SystemExit`` are ``BaseException`` (not
``Exception``) and therefore propagate unchanged.
"""
if isinstance(exc, OutputTooLongError):
return False
return isinstance(exc, Exception)
class _WeightedRoundRobin:
"""Smooth weighted round-robin scheduler (nginx SWRR).
Produces a starting member index per request such that, over time, member
``i`` is chosen in proportion to ``weights[i]`` while keeping selections
interleaved rather than bursty. Uniform weights degrade to plain round-robin.
The tiny selection critical section is mutex-guarded so concurrent callers
don't corrupt the running totals (they may still interleave, which only
affects distribution, never correctness).
"""
def __init__(self, weights: list[int]) -> None:
self._weights = list(weights)
self._current = [0] * len(weights)
self._total = sum(weights)
self._lock = threading.Lock()
def next(self) -> int:
with self._lock:
best = 0
for i, w in enumerate(self._weights):
self._current[i] += w
if self._current[i] > self._current[best]:
best = i
self._current[best] -= self._total
return best
class MultiLLMProvider:
"""Route LLM calls across multiple members per a failover / round-robin strategy."""
def __init__(self, members: list[LLMProvider], strategy: LLMStrategyConfig) -> None:
if not members:
raise ValueError("MultiLLMProvider requires at least one member")
self._members = members
self._strategy = strategy
weights = strategy.weights or [1] * len(members)
if len(weights) != len(members):
raise ValueError(
f"LLM strategy 'weights' has {len(weights)} entries but the chain has "
f"{len(members)} members (primary + indexed); they must match."
)
self._scheduler = _WeightedRoundRobin(weights)
# ── routing ────────────────────────────────────────────────────────────────
def _member_order(self) -> list[int]:
"""Indices to try, in order, for one request."""
n = len(self._members)
if self._strategy.mode == LLM_STRATEGY_FAILOVER:
return list(range(n))
start = self._scheduler.next()
return [(start + i) % n for i in range(n)]
async def _dispatch(self, method_name: str, **kwargs: Any) -> Any:
last_exc: BaseException | None = None
order = self._member_order()
for position, idx in enumerate(order):
member = self._members[idx]
try:
return await getattr(member, method_name)(**kwargs)
except BaseException as e: # noqa: BLE001 - re-raised unless it should fail over
if not _should_failover(e):
raise
last_exc = e
remaining = len(order) - position - 1
logger.warning(
"LLM member %d (%s/%s) failed on %s: %s%s",
idx,
member.provider,
member.model,
method_name,
e,
f"; trying next member ({remaining} left)" if remaining else "; no members left",
)
# All members failed; surface the last error (loop ran at least once).
assert last_exc is not None
raise last_exc
async def call(self, messages: list[dict[str, Any]], **kwargs: Any) -> Any:
return await self._dispatch("call", messages=messages, **kwargs)
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
**kwargs: Any,
) -> "LLMToolCallResult":
return await self._dispatch("call_with_tools", messages=messages, tools=tools, **kwargs)
# ── lifecycle ────────────────────────────────────────────────────────────────
async def verify_connection(self) -> None:
"""Strictly verify the primary; soft-verify the rest (warn, don't fail).
A failover member being unreachable at startup must not block the server
it may come back before it's needed. The primary is the steady-state path,
so its failure is still surfaced (the caller already wraps this in a
warn-only try/except at startup).
"""
await self._members[0].verify_connection()
for member in self._members[1:]:
try:
await member.verify_connection()
except Exception as e: # noqa: BLE001 - soft verification
logger.warning(
"Failover LLM member %s/%s failed connection verification: %s. "
"It will be tried at request time if the primary fails.",
member.provider,
member.model,
e,
)
async def cleanup(self) -> None:
for member in self._members:
await member.cleanup()
def with_config(
self,
config: Any,
*,
bank_id: str | None = None,
operation: str | None = None,
metadata: dict[str, Any] | None = None,
) -> "ConfiguredLLMProvider":
"""Mirror ``LLMProvider.with_config`` so the strategy runs inside the
per-operation configured wrapper (gemini-safety + trace contextvars wrap
every member call)."""
from .llm_trace import LLMTraceContext
from .llm_wrapper import ConfiguredLLMProvider
trace_ctx = None
if bank_id is not None or operation is not None or metadata:
trace_ctx = LLMTraceContext(
bank_id=bank_id,
operation=operation,
metadata=dict(metadata or {}),
trace_id=str(uuid.uuid4()),
operation_span_id=str(uuid.uuid4()),
)
return ConfiguredLLMProvider(self, config.llm_gemini_safety_settings, trace_ctx)
# ── attribute passthrough ────────────────────────────────────────────────────
@property
def members(self) -> list[LLMProvider]:
return self._members
def __getattr__(self, name: str) -> Any:
# Anything not defined here (provider, model, api_key, base_url,
# _provider_impl, mock helpers, batch helpers, ...) delegates to the
# primary member so existing call sites keep working unchanged.
return getattr(object.__getattribute__(self, "_members")[0], name)
@@ -3,138 +3,43 @@
import asyncio
import logging
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from hindsight_api.config import DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT
from .base import FileParser
if TYPE_CHECKING:
from markitdown import StreamInfo
logger = logging.getLogger(__name__)
# Extensions whose markitdown converters decode the raw bytes as text. markitdown
# samples only the first chunk for charset detection, so a UTF-8 file with a long
# ASCII-only prefix is mis-detected as ASCII; the JSON/ipynb converter then crashes
# decoding the first multibyte byte. Passing an explicit UTF-8 hint when the bytes
# are valid UTF-8 sidesteps the faulty detection without affecting other encodings.
_TEXT_EXTENSIONS = {
".json",
".jsonl",
".ipynb",
".txt",
".text",
".md",
".markdown",
".csv",
".html",
".htm",
}
@dataclass(frozen=True)
class MarkitdownOcrOptions:
"""OpenAI-compatible OCR options passed through to MarkItDown."""
# Keep this typed as object so the OpenAI SDK import stays lazy for non-OCR users.
llm_client: object
llm_model: str
llm_prompt: str
class MarkitdownParser(FileParser):
"""
Markitdown file parser.
Uses Microsoft's markitdown library to convert various file formats
to markdown including PDF, Office docs, images with optional OCR,
audio, HTML.
to markdown including PDF, Office docs, images (via OCR), audio, HTML.
Supported formats:
- PDF (.pdf)
- Word (.docx, .doc)
- PowerPoint (.pptx, .ppt)
- Excel (.xlsx, .xls)
- Images (.jpg, .jpeg, .png) - optional OCR
- Images (.jpg, .jpeg, .png) - with OCR
- HTML (.html, .htm)
- Text (.txt, .md)
- Audio (.mp3, .wav) - with transcription
"""
def __init__(
self,
*,
ocr_enabled: bool = False,
ocr_api_key: str | None = None,
ocr_base_url: str | None = None,
ocr_model: str | None = None,
ocr_prompt: str | None = None,
):
def __init__(self):
"""Initialize markitdown parser."""
# Lazy import to avoid requiring markitdown for all users
try:
from markitdown import MarkItDown
self._markitdown = MarkItDown()
except ImportError as e:
raise ImportError(
"markitdown package is required for file parsing. Install with: pip install markitdown"
) from e
self._ocr_enabled = ocr_enabled
if ocr_enabled:
ocr_options = self._build_ocr_options(
api_key=ocr_api_key,
base_url=ocr_base_url,
model=ocr_model,
prompt=ocr_prompt,
)
self._markitdown = MarkItDown(
llm_client=ocr_options.llm_client,
llm_model=ocr_options.llm_model,
llm_prompt=ocr_options.llm_prompt,
)
else:
self._markitdown = MarkItDown()
def _build_ocr_options(
self,
*,
api_key: str | None,
base_url: str | None,
model: str | None,
prompt: str | None,
) -> MarkitdownOcrOptions:
"""Build MarkItDown options for OpenAI-compatible image OCR."""
if not model or not model.strip():
raise ValueError(
"Markitdown OCR is enabled but no model is configured. "
"Set HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL to an OpenAI-compatible OCR/vision model "
"with image-input support."
)
if not api_key:
raise ValueError(
"Markitdown OCR is enabled but no API key is configured. "
"Set HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY."
)
if not base_url or not base_url.strip():
raise ValueError(
"Markitdown OCR is enabled but no base URL is configured. "
"Set HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL to an OpenAI-compatible OCR/vision endpoint."
)
try:
from openai import OpenAI
except ImportError as e:
raise RuntimeError("openai package is required when Markitdown OCR is enabled.") from e
return MarkitdownOcrOptions(
llm_client=OpenAI(api_key=api_key, base_url=base_url.strip()),
llm_model=model.strip(),
llm_prompt=prompt or DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT,
)
async def convert(self, file_data: bytes, filename: str) -> str:
"""Parse file to markdown using markitdown."""
# markitdown is synchronous, so we run it in executor to avoid blocking
@@ -143,22 +48,14 @@ class MarkitdownParser(FileParser):
def _convert_sync(self, file_data: bytes, filename: str) -> str:
"""Synchronous parsing (runs in thread pool)."""
if self._is_image_file(filename) and not self._ocr_enabled:
raise RuntimeError(
"Image OCR is not enabled for the markitdown parser. "
"Set HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED=true and configure an OpenAI-compatible "
"OCR/vision endpoint with image-input support, or choose an OCR-capable parser."
)
# Write to temp file (markitdown requires file path)
with tempfile.NamedTemporaryFile(suffix=Path(filename).suffix, delete=False) as tmp:
tmp.write(file_data)
tmp_path = tmp.name
try:
# Parse using markitdown, passing an explicit charset hint for text
# files to avoid markitdown's sample-based (and crash-prone) detection.
result = self._markitdown.convert(tmp_path, stream_info=self._utf8_stream_info(file_data, filename))
# Parse using markitdown
result = self._markitdown.convert(tmp_path)
if not result or not result.text_content:
raise RuntimeError(f"No content extracted from '{filename}'")
@@ -176,28 +73,6 @@ class MarkitdownParser(FileParser):
except Exception:
pass
@staticmethod
def _utf8_stream_info(file_data: bytes, filename: str) -> "StreamInfo | None":
"""Return a UTF-8 charset hint for text files that decode cleanly as UTF-8.
Returns None for binary files or non-UTF-8 text so markitdown falls back
to its own detection.
"""
if Path(filename).suffix.lower() not in _TEXT_EXTENSIONS:
return None
try:
file_data.decode("utf-8")
except UnicodeDecodeError:
return None
from markitdown import StreamInfo
return StreamInfo(charset="utf-8")
@staticmethod
def _is_image_file(filename: str) -> bool:
"""Return whether the file type needs OCR to extract useful text."""
return Path(filename).suffix.lower() in {".jpg", ".jpeg", ".png"}
def supports(self, filename: str, content_type: str | None = None) -> bool:
"""Check if markitdown supports this file type."""
# Supported extensions (from markitdown docs)
@@ -210,7 +85,7 @@ class MarkitdownParser(FileParser):
".ppt",
".xlsx",
".xls",
# Images (optional OCR)
# Images (with OCR)
".jpg",
".jpeg",
".png",
@@ -14,26 +14,13 @@ import logging
import time
from typing import Any
from hindsight_api.engine.llm_interface import LLMInterface
from hindsight_api.engine.llm_trace import LLMResponseUsage, stash_response_usage
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
logger = logging.getLogger(__name__)
def _usage_from_anthropic_response(response: Any) -> LLMResponseUsage:
"""Extract input/output/cached token counts from an Anthropic usage block."""
usage = getattr(response, "usage", None)
if not usage:
return LLMResponseUsage()
return LLMResponseUsage(
input_tokens=usage.input_tokens or 0,
output_tokens=usage.output_tokens or 0,
cached_tokens=getattr(usage, "cache_read_input_tokens", 0) or 0,
)
class AnthropicLLM(LLMInterface):
"""
LLM provider using Anthropic's Claude models.
@@ -149,9 +136,7 @@ class AnthropicLLM(LLMInterface):
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
skip_validation: Return raw JSON without Pydantic validation.
strict_schema: Route structured output through a forced tool_use tool for
native constrained decoding (issue #1002). When False, falls back to
schema-in-prompt + JSON parse.
strict_schema: Use strict JSON schema enforcement (not supported by Anthropic).
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
Returns:
@@ -182,21 +167,14 @@ class AnthropicLLM(LLMInterface):
else:
anthropic_messages.append({"role": role, "content": content})
# Structured output: prefer Anthropic-native constrained decoding via a single
# forced tool_use tool (strict_schema) over text-injecting the schema and
# parsing the reply. Native constrained decoding guarantees schema-valid JSON,
# eliminating the invalid-JSON retry storm (issue #1002). When strict_schema is
# off we keep the text-inject + json.loads fallback for backward compatibility.
schema = None
use_forced_tool = False
_tool_name = "structured_response"
# Add JSON schema instruction if response_format is provided
if response_format is not None and hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
if strict_schema:
use_forced_tool = True
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
if system_prompt:
system_prompt += schema_msg
else:
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
system_prompt = (system_prompt + schema_msg) if system_prompt else schema_msg
system_prompt = schema_msg
# Prepare parameters
call_params: dict[str, Any] = {
@@ -208,14 +186,6 @@ class AnthropicLLM(LLMInterface):
if system_prompt:
call_params["system"] = system_prompt
if use_forced_tool:
# Single tool whose input_schema IS the response schema; force the model to
# emit it via tool_choice so the SDK does constrained decoding for us.
call_params["tools"] = [
{"name": _tool_name, "description": "Return the structured response.", "input_schema": schema}
]
call_params["tool_choice"] = {"type": "tool", "name": _tool_name}
if self._extra_body:
call_params["extra_body"] = self._extra_body
@@ -224,61 +194,40 @@ class AnthropicLLM(LLMInterface):
for attempt in range(max_retries + 1):
try:
response = await self._client.messages.create(**call_params)
# Stash usage before parse/validate, which may raise locally
# even though the provider charged for these tokens (#2387).
stash_response_usage(_usage_from_anthropic_response(response))
if use_forced_tool:
# Forced tool_use → the validated args are already a dict; no parsing,
# no markdown-strip, no JSON-decode retry possible.
tool_input = None
for block in response.content:
if block.type == "tool_use" and block.name == _tool_name:
tool_input = block.input or {}
break
if tool_input is None:
# Model ignored the forced tool (rare, e.g. a gateway that drops
# tool_choice). Fall back to text parse so we don't hard-fail; the
# existing retry loop still covers genuine errors.
content = "".join(b.text for b in response.content if b.type == "text")
tool_input = json.loads(content)
content = json.dumps(tool_input)
result = tool_input if skip_validation else response_format.model_validate(tool_input)
else:
# Anthropic response content is a list of blocks
content = ""
for block in response.content:
if block.type == "text":
content += block.text
# Anthropic response content is a list of blocks
content = ""
for block in response.content:
if block.type == "text":
content += block.text
if response_format is not None:
# Models may wrap JSON in markdown code blocks
clean_content = content
if "```json" in content:
clean_content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
clean_content = content.split("```")[1].split("```")[0].strip()
if response_format is not None:
# Models may wrap JSON in markdown code blocks
clean_content = content
if "```json" in content:
clean_content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
clean_content = content.split("```")[1].split("```")[0].strip()
try:
json_data = json.loads(clean_content)
except json.JSONDecodeError:
# Fallback to parsing raw content if markdown stripping failed
json_data = json.loads(content)
try:
json_data = json.loads(clean_content)
except json.JSONDecodeError:
# Fallback to parsing raw content if markdown stripping failed
json_data = json.loads(content)
if skip_validation:
result = json_data
else:
result = response_format.model_validate(json_data)
if skip_validation:
result = json_data
else:
result = content
result = response_format.model_validate(json_data)
else:
result = content
# Record metrics and log slow calls
duration = time.time() - start_time
response_usage = _usage_from_anthropic_response(response)
input_tokens = response_usage.input_tokens
output_tokens = response_usage.output_tokens
input_tokens = response.usage.input_tokens or 0 if response.usage else 0
output_tokens = response.usage.output_tokens or 0 if response.usage else 0
total_tokens = input_tokens + output_tokens
cached_tokens = response_usage.cached_tokens
cached_tokens = getattr(response.usage, "cache_read_input_tokens", 0) or 0 if response.usage else 0
# Record LLM metrics
metrics = get_metrics_collector()
@@ -466,7 +415,6 @@ class AnthropicLLM(LLMInterface):
for attempt in range(max_retries + 1):
try:
response = await self._client.messages.create(**call_params)
stash_response_usage(_usage_from_anthropic_response(response))
# Extract content and tool calls
content_parts = []
@@ -15,8 +15,7 @@ from typing import Any
from pydantic import ValidationError
from hindsight_api.engine.llm_interface import LLMInterface
from hindsight_api.engine.llm_trace import LLMResponseUsage, stash_response_usage
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
@@ -119,14 +118,12 @@ class ClaudeCodeLLM(LLMInterface):
Raises:
RuntimeError: If the connection test fails.
"""
from ...config import get_config
try:
test_messages = [{"role": "user", "content": "test"}]
await self.call(
messages=test_messages,
max_completion_tokens=10,
temperature=get_config().llm_temperature_verification,
temperature=0.0,
scope="verification",
max_retries=0,
)
@@ -229,16 +226,6 @@ class ClaudeCodeLLM(LLMInterface):
if isinstance(block, TextBlock):
full_text += block.text
# The Claude Agent SDK doesn't report exact counts; stash the same
# char/4 estimate the success path traces so a later parse/validate
# failure records consistent (estimated) tokens, not zero (#2387).
stash_response_usage(
LLMResponseUsage(
input_tokens=sum(len(m.get("content", "")) for m in messages) // 4,
output_tokens=len(full_text) // 4,
)
)
# Handle structured output
if response_format is not None:
# Models may wrap JSON in markdown
@@ -60,22 +60,6 @@ _CODEX_TERMINAL_REFRESH_ERROR_CODES = frozenset(
)
def default_codex_auth_file() -> Path:
"""Return the path to Codex's ``auth.json``.
Honors the ``CODEX_HOME`` environment variable the same variable the
canonical ``@openai/codex`` CLI uses to relocate its config/credentials
directory and falls back to ``~/.codex`` when it is unset or empty.
Resolved lazily on each call (rather than cached at import time) so that
the environment is read at the point of use.
"""
codex_home = os.environ.get("CODEX_HOME")
if codex_home:
return Path(codex_home) / "auth.json"
return Path.home() / ".codex" / "auth.json"
class CodexRefreshExpiredError(RuntimeError):
"""Raised when the Codex refresh_token itself is no longer valid.
@@ -102,7 +86,7 @@ class CodexAuthManager:
The OAuth refresh token. May be ``None`` when the auth file omits it;
the provider still works as a one-shot loader in that case.
auth_file:
Path to the Codex ``auth.json``. Used for re-reading the refresh token
Path to ``~/.codex/auth.json``. Used for re-reading the refresh token
on demand and for atomic persistence of rotated credentials.
"""
@@ -131,8 +115,7 @@ class CodexAuthManager:
Parameters
----------
auth_file:
Defaults to ``$CODEX_HOME/auth.json`` (or ``~/.codex/auth.json``
when ``CODEX_HOME`` is unset).
Defaults to ``~/.codex/auth.json``.
Raises
------
@@ -143,7 +126,7 @@ class CodexAuthManager:
``auth_mode``.
"""
if auth_file is None:
auth_file = default_codex_auth_file()
auth_file = Path.home() / ".codex" / "auth.json"
if not auth_file.exists():
raise FileNotFoundError(f"Codex auth file not found: {auth_file}. Run 'codex auth login' to authenticate.")
@@ -2,9 +2,8 @@
OpenAI Codex LLM provider using ChatGPT Plus/Pro OAuth authentication.
This provider enables using ChatGPT Plus/Pro subscriptions for API calls
without separate OpenAI Platform API credits. It uses OAuth tokens from the
Codex ``auth.json`` (``$CODEX_HOME/auth.json``, or ``~/.codex/auth.json`` when
``CODEX_HOME`` is unset) and communicates with the ChatGPT backend API.
without separate OpenAI Platform API credits. It uses OAuth tokens from
~/.codex/auth.json and communicates with the ChatGPT backend API.
Tokens are refreshed automatically: the provider decodes the access_token
JWT's ``exp`` claim and proactively refreshes via
@@ -25,8 +24,7 @@ from typing import Any
import httpx
from hindsight_api.engine.llm_interface import LLMInterface
from hindsight_api.engine.llm_trace import LLMResponseUsage, stash_response_usage
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
@@ -37,7 +35,6 @@ from .codex_auth import (
_CODEX_TOKEN_REFRESH_SKEW_SECONDS,
CodexAuthManager,
CodexRefreshExpiredError,
default_codex_auth_file,
)
# Re-export for backward compatibility (tests import from this module).
@@ -58,15 +55,14 @@ class CodexLLM(LLMInterface):
"""
LLM provider using OpenAI Codex OAuth authentication.
Authenticates using ChatGPT Plus/Pro credentials stored in the Codex
``auth.json`` (honoring ``CODEX_HOME``, default ``~/.codex``) and makes API
calls to chatgpt.com/backend-api/codex/responses.
Authenticates using ChatGPT Plus/Pro credentials stored in ~/.codex/auth.json
and makes API calls to chatgpt.com/backend-api/codex/responses.
"""
def __init__(
self,
provider: str,
api_key: str, # Will be ignored, reads from the Codex auth.json (CODEX_HOME or ~/.codex)
api_key: str, # Will be ignored, reads from ~/.codex/auth.json
base_url: str,
model: str,
reasoning_effort: str = "low",
@@ -85,14 +81,12 @@ class CodexLLM(LLMInterface):
refresh_token = self._load_codex_refresh_token()
logger.info(f"Loaded Codex OAuth credentials for account: {account_id}")
except Exception as e:
auth_file = default_codex_auth_file()
raise RuntimeError(
f"Failed to load Codex OAuth credentials from {auth_file}: {e}\n\n"
f"Failed to load Codex OAuth credentials from ~/.codex/auth.json: {e}\n\n"
"To set up Codex authentication:\n"
"1. Install Codex CLI: npm install -g @openai/codex\n"
"2. Login: codex auth login\n"
f"3. Verify: ls {auth_file}\n\n"
"(Set CODEX_HOME to use a credentials directory other than ~/.codex.)\n\n"
"3. Verify: ls ~/.codex/auth.json\n\n"
"Or use a different provider (openai, anthropic, gemini) with API keys."
) from e
@@ -100,7 +94,7 @@ class CodexLLM(LLMInterface):
access_token=access_token,
account_id=account_id,
refresh_token=refresh_token,
auth_file=default_codex_auth_file(),
auth_file=Path.home() / ".codex" / "auth.json",
)
# Use ChatGPT backend API endpoint. Codex auth is tied to
@@ -162,7 +156,7 @@ class CodexLLM(LLMInterface):
def _load_codex_auth(self) -> tuple[str, str]:
"""
Load OAuth credentials from the Codex ``auth.json`` (CODEX_HOME or ~/.codex).
Load OAuth credentials from ~/.codex/auth.json.
Returns:
Tuple of (access_token, account_id).
@@ -171,7 +165,7 @@ class CodexLLM(LLMInterface):
FileNotFoundError: If auth file doesn't exist.
ValueError: If auth file is invalid.
"""
auth_file = default_codex_auth_file()
auth_file = Path.home() / ".codex" / "auth.json"
if not auth_file.exists():
raise FileNotFoundError(
@@ -203,7 +197,9 @@ class CodexLLM(LLMInterface):
pre- and post-``__init__`` because it does not depend on
``_auth_manager`` being constructed yet.
"""
auth_file = self._auth_manager._auth_file if hasattr(self, "_auth_manager") else default_codex_auth_file()
auth_file = (
self._auth_manager._auth_file if hasattr(self, "_auth_manager") else Path.home() / ".codex" / "auth.json"
)
return CodexAuthManager.load_refresh_token_from_file(auth_file)
@staticmethod
@@ -401,6 +397,7 @@ class CodexLLM(LLMInterface):
}
url = f"{self.base_url}/codex/responses"
last_exception = None
# Manual attempt tracking instead of ``for attempt in range(...)`` so
# that the reactive-refresh path can retry once without consuming a
@@ -415,16 +412,6 @@ class CodexLLM(LLMInterface):
# Parse SSE stream
content = await self._parse_sse_stream(response)
# Codex SSE carries no usage block; stash the same char/4 estimate
# the success path traces so a later parse/validate failure records
# consistent (estimated) token counts rather than zero (#2387).
stash_response_usage(
LLMResponseUsage(
input_tokens=sum(len(m.get("content", "")) for m in messages) // 4,
output_tokens=len(content) // 4,
)
)
# Handle structured output
if response_format is not None:
# Models may wrap JSON in markdown
@@ -441,6 +428,7 @@ class CodexLLM(LLMInterface):
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
last_exception = e
attempt += 1
continue
raise
@@ -502,6 +490,7 @@ class CodexLLM(LLMInterface):
return result
except httpx.HTTPStatusError as e:
last_exception = e
status_code = e.response.status_code
# Auth error: try one OAuth refresh + retry before giving up.
@@ -560,6 +549,7 @@ class CodexLLM(LLMInterface):
raise
except httpx.RequestError as e:
last_exception = e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
logger.warning(f"Codex connection error (attempt {attempt + 1}/{max_retries + 1}): {e}")
@@ -574,6 +564,10 @@ class CodexLLM(LLMInterface):
logger.error(f"Unexpected Codex error: {type(e).__name__}: {e}")
raise
if last_exception:
raise last_exception
raise RuntimeError("Codex call failed after all retries")
async def _parse_sse_stream(self, response: httpx.Response) -> str:
"""
Parse Server-Sent Events (SSE) stream from Codex API.
@@ -8,9 +8,9 @@ This provider supports both:
import asyncio
import base64
import io
import json
import logging
import os
import time
from contextvars import ContextVar
from typing import Any
@@ -19,8 +19,7 @@ from google import genai
from google.genai import errors as genai_errors
from google.genai import types as genai_types
from hindsight_api.engine.llm_interface import LLMInterface
from hindsight_api.engine.llm_trace import LLMResponseUsage, stash_response_usage
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
@@ -36,6 +35,7 @@ _safety_settings_ctx: ContextVar[list | None] = ContextVar("gemini_safety_settin
# Vertex AI imports (optional)
try:
import google.auth
from google.oauth2 import service_account
VERTEXAI_AVAILABLE = True
@@ -43,26 +43,6 @@ except ImportError:
VERTEXAI_AVAILABLE = False
def _to_int(value: Any) -> int:
"""Coerce Gemini's optional/string completion counts to int, defaulting to 0."""
try:
return int(value)
except (ValueError, TypeError):
return 0
def _usage_from_gemini_response(response: Any) -> LLMResponseUsage:
"""Extract prompt/candidate/cached token counts from a Gemini usage_metadata block."""
usage = getattr(response, "usage_metadata", None)
if not usage:
return LLMResponseUsage()
return LLMResponseUsage(
input_tokens=usage.prompt_token_count or 0,
output_tokens=usage.candidates_token_count or 0,
cached_tokens=getattr(usage, "cached_content_token_count", 0) or 0,
)
class GeminiLLM(LLMInterface):
"""
LLM provider for Google Gemini and Vertex AI.
@@ -89,7 +69,6 @@ class GeminiLLM(LLMInterface):
# Safety settings: None means use Gemini's defaults
self._safety_settings: list | None = kwargs.get("gemini_safety_settings")
self._service_tier: str | None = kwargs.get("gemini_service_tier")
# User-configured extra params merged into the GenerateContentConfig of
# every call. Gemini's request body nests generation params, so we expose
@@ -120,16 +99,6 @@ class GeminiLLM(LLMInterface):
self._client = genai.Client(api_key=self.api_key)
logger.info(f"Gemini API: model={self.model}")
def _apply_service_tier(self, config_kwargs: dict[str, Any]) -> None:
if not self._service_tier:
return
http_options = dict(config_kwargs.get("http_options") or {})
extra_body = dict(http_options.get("extra_body") or {})
extra_body.setdefault("service_tier", self._service_tier)
http_options["extra_body"] = extra_body
config_kwargs["http_options"] = http_options
def _init_vertexai(self, **kwargs: Any) -> None:
"""Initialize Vertex AI client with project, region, and credentials."""
# Extract Vertex AI config from kwargs
@@ -271,13 +240,16 @@ class GeminiLLM(LLMInterface):
else:
gemini_contents.append(genai_types.Content(role="user", parts=[genai_types.Part(text=content)]))
def _system_instruction_with_schema() -> str:
# Add the JSON schema as a textual hint in the system_instruction (matching
# the normal uncached path). Structured output is still enforced via
# response_schema regardless; this is just guidance text.
if response_format is not None and hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
schema_msg = (
f"\n\nYou must respond with valid JSON matching this schema:\n"
f"{json.dumps(schema, indent=2, ensure_ascii=False)}"
)
return (system_instruction + schema_msg) if system_instruction else schema_msg
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
if system_instruction:
system_instruction += schema_msg
else:
system_instruction = schema_msg
# Apply safety settings: context var (per-request bank override) takes precedence over instance default
effective_safety_settings = _safety_settings_ctx.get()
@@ -294,18 +266,11 @@ class GeminiLLM(LLMInterface):
def _build_generation_config(use_cache: bool) -> "genai_types.GenerateContentConfig | None":
# Seed with user-configured extra params; explicit settings below win.
config_kwargs: dict[str, Any] = dict(self._extra_body)
self._apply_service_tier(config_kwargs)
if use_cache:
config_kwargs["cached_content"] = cached_prefix
elif (
use_schema_prompt_fallback
and response_format is not None
and hasattr(response_format, "model_json_schema")
):
config_kwargs["system_instruction"] = _system_instruction_with_schema()
elif system_instruction:
config_kwargs["system_instruction"] = system_instruction
if response_format is not None and not use_schema_prompt_fallback:
if response_format is not None:
config_kwargs["response_mime_type"] = "application/json"
config_kwargs["response_schema"] = response_format
if temperature is not None:
@@ -323,7 +288,6 @@ class GeminiLLM(LLMInterface):
return genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None
cache_active = using_cache
use_schema_prompt_fallback = False
generation_config = _build_generation_config(cache_active)
last_exception = None
@@ -340,9 +304,6 @@ class GeminiLLM(LLMInterface):
),
timeout=90.0, # Safety net for network hangs; valid slow responses are <90s
)
# Stash usage before parse/validate, which may raise locally
# even though the provider charged for these tokens (#2387).
stash_response_usage(_usage_from_gemini_response(response))
content = response.text
@@ -444,26 +405,12 @@ class GeminiLLM(LLMInterface):
output_tokens=output_tokens,
total_tokens=input_tokens + output_tokens,
cached_tokens=cached_tokens,
thoughts_tokens=thoughts_tokens,
)
return result, token_usage
return result
except json.JSONDecodeError as e:
last_exception = e
if (
attempt < max_retries
and response_format is not None
and hasattr(response_format, "model_json_schema")
and not cache_active
and not use_schema_prompt_fallback
):
logger.warning("Gemini returned invalid JSON, retrying with prompt-side schema guidance...")
cache_active = False
use_schema_prompt_fallback = True
generation_config = _build_generation_config(cache_active)
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
continue
if attempt < max_retries:
logger.warning("Gemini returned invalid JSON, retrying...")
backoff = min(initial_backoff * (2**attempt), max_backoff)
@@ -650,7 +597,6 @@ class GeminiLLM(LLMInterface):
def _build_tools_config(use_cache: bool) -> "genai_types.GenerateContentConfig":
# Seed with user-configured extra params; explicit settings below win.
config_kwargs: dict[str, Any] = dict(self._extra_body)
self._apply_service_tier(config_kwargs)
if use_cache:
config_kwargs["cached_content"] = cached_prefix
else:
@@ -709,7 +655,6 @@ class GeminiLLM(LLMInterface):
),
timeout=90.0, # Safety net for network hangs; valid slow responses are <90s
)
stash_response_usage(_usage_from_gemini_response(response))
# Extract content and tool calls
content = None
@@ -797,8 +742,6 @@ class GeminiLLM(LLMInterface):
finish_reason=finish_reason,
input_tokens=input_tokens,
output_tokens=output_tokens,
cached_tokens=cached_input_tokens,
thoughts_tokens=thoughts_tokens,
)
except genai_errors.APIError as e:
@@ -883,282 +826,6 @@ class GeminiLLM(LLMInterface):
tools=tools,
)
# ── Batch API (Gemini API only — not Vertex AI) ─────────────────────────
#
# Google's Gemini Batch API gives a flat 50% discount on input + output
# tokens with a 24h completion SLA (https://ai.google.dev/gemini-api/docs/batch-api).
# The retain orchestrator and ``fact_extraction`` consumer speak the
# OpenAI-batch interface contract, so these overrides translate that shape
# to/from Gemini's file-upload → ``batches.create`` → ``batches.get`` →
# download flow — nothing downstream changes (same pattern as FireworksLLM).
#
# Interface contract preserved (see fact_extraction.py result handling)::
# result["response"]["body"]["choices"][0]["message"]["content"]
async def supports_batch_api(self) -> bool:
"""True for the Gemini API; False for Vertex AI.
Only ``provider="gemini"`` is supported: it exposes the file-upload
Batch API used below. Vertex AI's batch path is GCS/BigQuery-backed (no
file-upload analogue), so it stays unsupported here the startup
validation then surfaces a clear error instead of silently falling back
to synchronous, full-price calls.
"""
return self.provider == "gemini"
async def submit_batch(
self,
requests: list[dict[str, Any]],
endpoint: str = "/v1/chat/completions",
completion_window: str = "24h",
) -> dict[str, Any]:
"""Submit a batch of (OpenAI-shaped) requests to the Gemini Batch API."""
if not await self.supports_batch_api():
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
# endpoint/completion_window are part of the shared LLMInterface batch
# contract (used by the OpenAI path) but have no analogue on Gemini: the
# request shape is fixed (generateContent) and the SLA is server-side.
# Kept for signature compatibility with the shared retain driver.
logger.info(f"Submitting Gemini batch with {len(requests)} requests")
jsonl = self._translate_requests(requests)
# Upload the JSONL as a Gemini file (mime_type must be "jsonl"; a
# BytesIO has no path for the SDK to infer it from).
file_obj = io.BytesIO(jsonl.encode("utf-8"))
uploaded = await self._client.aio.files.upload(
file=file_obj,
config=genai_types.UploadFileConfig(mime_type="jsonl", display_name="hindsight-batch-input"),
)
batch = await self._client.aio.batches.create(
model=self.model,
src=uploaded.name,
config=genai_types.CreateBatchJobConfig(display_name="hindsight-batch"),
)
logger.info(f"Gemini batch submitted: {batch.name}, state={self._state_name(batch.state)}")
return {
"batch_id": batch.name,
"status": self._normalize_state(batch.state),
"input_file_id": uploaded.name,
"request_count": len(requests),
}
async def get_batch_status(self, batch_id: str) -> dict[str, Any]:
"""Get the status of a Gemini batch job, in the shared status shape."""
if not await self.supports_batch_api():
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
batch = await self._client.aio.batches.get(name=batch_id)
stats = batch.completion_stats
successful = _to_int(getattr(stats, "successful_count", None)) if stats else 0
failed = _to_int(getattr(stats, "failed_count", None)) if stats else 0
incomplete = _to_int(getattr(stats, "incomplete_count", None)) if stats else 0
result: dict[str, Any] = {
"batch_id": batch.name,
"status": self._normalize_state(batch.state),
"request_counts": {
"total": successful + failed + incomplete,
"completed": successful,
"failed": failed,
},
}
if batch.dest and getattr(batch.dest, "file_name", None):
result["output_file_id"] = batch.dest.file_name
if batch.error:
result["errors"] = self._error_to_dict(batch.error)
return result
async def retrieve_batch_results(self, batch_id: str) -> list[dict[str, Any]]:
"""Download and normalize completed Gemini batch results."""
if not await self.supports_batch_api():
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
batch = await self._client.aio.batches.get(name=batch_id)
status = self._normalize_state(batch.state)
if status != "completed":
raise ValueError(f"Gemini batch {batch_id} is not completed yet (state: {self._state_name(batch.state)})")
dest = batch.dest
if not dest or not getattr(dest, "file_name", None):
raise ValueError(
f"Gemini batch {batch_id} completed but reported no output file "
f"(submit_batch always uses file mode, so this is unexpected)"
)
content = await self._client.aio.files.download(file=dest.file_name)
text = content.decode("utf-8") if isinstance(content, (bytes, bytearray)) else str(content)
# The output is a JSONL error file plus results merged into one stream;
# error lines carry an `error` so partial failures surface per key
# instead of vanishing (JOB_STATE_PARTIALLY_SUCCEEDED maps to completed).
results: list[dict[str, Any]] = []
for line in text.strip().split("\n"):
if line.strip():
results.append(self._normalize_output_line(json.loads(line)))
logger.info(f"Retrieved {len(results)} results for Gemini batch {batch_id}")
return results
# ----- pure translation/normalization helpers (unit-tested) ----------
@staticmethod
def _translate_requests(requests: list[dict[str, Any]]) -> str:
"""OpenAI batch requests -> Gemini batch input JSONL.
Each output line is ``{"key": <custom_id>, "request": <GenerateContentRequest>}``;
the model is supplied to ``batches.create`` so it is omitted per-line.
"""
lines = []
for req in requests:
gemini_request = GeminiLLM._openai_body_to_gemini_request(req.get("body") or {})
lines.append(json.dumps({"key": req.get("custom_id"), "request": gemini_request}, ensure_ascii=False))
return "\n".join(lines)
@staticmethod
def _openai_body_to_gemini_request(body: dict[str, Any]) -> dict[str, Any]:
"""OpenAI chat-completions body -> Gemini ``GenerateContentRequest`` JSON.
Mirrors the synchronous ``call`` path: system messages become
``systemInstruction``; a ``response_format`` json_schema forces JSON
output (``responseMimeType``), appends the schema as a textual hint, and
grammar-enforces via ``responseJsonSchema`` when ``strict`` is set.
"""
system_texts: list[str] = []
contents: list[dict[str, Any]] = []
for msg in body.get("messages") or []:
role = msg.get("role", "user")
text = msg.get("content", "") or ""
if role == "system":
system_texts.append(text)
elif role == "assistant":
contents.append({"role": "model", "parts": [{"text": text}]})
else:
contents.append({"role": "user", "parts": [{"text": text}]})
generation_config: dict[str, Any] = {}
if body.get("temperature") is not None:
generation_config["temperature"] = body["temperature"]
if body.get("max_completion_tokens") is not None:
generation_config["maxOutputTokens"] = body["max_completion_tokens"]
response_format = body.get("response_format")
if isinstance(response_format, dict) and response_format.get("type") == "json_schema":
json_schema = response_format.get("json_schema") or {}
schema = json_schema.get("schema")
generation_config["responseMimeType"] = "application/json"
if schema:
system_texts.append(
"You must respond with valid JSON matching this schema:\n" + json.dumps(schema, ensure_ascii=False)
)
if json_schema.get("strict"):
generation_config["responseJsonSchema"] = schema
request: dict[str, Any] = {"contents": contents}
if system_texts:
request["systemInstruction"] = {"parts": [{"text": "\n\n".join(system_texts)}]}
if generation_config:
request["generationConfig"] = generation_config
return request
@staticmethod
def _normalize_output_line(line: dict[str, Any]) -> dict[str, Any]:
"""Gemini batch output line -> OpenAI-batch-output shape.
Target: ``{"custom_id", "response": {"body": {"choices": [...], "usage": {...}}}, "error"}``
so the consumer's ``result["response"]["body"]["choices"][0]...`` works and
it can read ``body["usage"]`` for token accounting (the consumer reports
zero usage otherwise).
"""
custom_id = line.get("key") if line.get("key") is not None else line.get("custom_id")
error = line.get("error")
if error:
return {"custom_id": custom_id, "response": None, "error": error}
response = line.get("response") or {}
body: dict[str, Any] = {"choices": [{"message": {"content": GeminiLLM._extract_text_from_response(response)}}]}
usage = GeminiLLM._usage_from_response(response)
if usage is not None:
body["usage"] = usage
return {"custom_id": custom_id, "response": {"body": body}, "error": None}
@staticmethod
def _extract_text_from_response(response: dict[str, Any]) -> str:
"""Concatenate the text parts of a (JSON) GenerateContentResponse."""
candidates = response.get("candidates") or []
if not candidates:
return ""
content = candidates[0].get("content") or {}
parts = content.get("parts") or []
return "".join(p.get("text", "") for p in parts if isinstance(p, dict) and p.get("text"))
@staticmethod
def _usage_from_response(response: dict[str, Any]) -> dict[str, Any] | None:
"""Gemini ``usageMetadata`` -> OpenAI-shaped ``usage`` block, or None.
The batch consumer accumulates token usage from ``body["usage"]`` using
OpenAI key names, so translate here to keep the output contract uniform
across providers. Handles both the REST camelCase (downloaded JSONL) and
snake_case spellings defensively.
"""
meta = response.get("usageMetadata") or response.get("usage_metadata")
if not isinstance(meta, dict):
return None
prompt = meta.get("promptTokenCount") or meta.get("prompt_token_count") or 0
completion = meta.get("candidatesTokenCount") or meta.get("candidates_token_count") or 0
total = meta.get("totalTokenCount") or meta.get("total_token_count") or 0
return {"prompt_tokens": prompt, "completion_tokens": completion, "total_tokens": total}
@staticmethod
def _normalize_state(state: Any) -> str:
"""Gemini ``JobState`` -> the retain driver's status strings.
Unknown / in-flight states map to ``in_progress`` so the driver keeps
polling; ``PARTIALLY_SUCCEEDED`` maps to ``completed`` (per-line errors
surface the partial failures during retrieval).
"""
name = GeminiLLM._state_name(state).upper()
if name in ("JOB_STATE_SUCCEEDED", "JOB_STATE_PARTIALLY_SUCCEEDED"):
return "completed"
if name == "JOB_STATE_FAILED":
return "failed"
if name in ("JOB_STATE_CANCELLED", "JOB_STATE_CANCELLING"):
return "cancelled"
if name == "JOB_STATE_EXPIRED":
return "expired"
return "in_progress"
@staticmethod
def _state_name(state: Any) -> str:
"""Extract the bare ``JOB_STATE_*`` name from a JobState enum or string."""
if state is None:
return ""
name = getattr(state, "name", None)
if name:
return str(name)
text = str(state)
if "." in text:
text = text.rsplit(".", 1)[-1]
return text
@staticmethod
def _error_to_dict(error: Any) -> dict[str, Any]:
"""Coerce a Gemini JobError into a JSON-serializable dict for logging."""
if hasattr(error, "model_dump"):
try:
return error.model_dump(exclude_none=True)
except Exception:
pass
return {"message": str(error)}
async def cleanup(self) -> None:
"""Clean up resources (close connections, etc.)."""
# Gemini client doesn't require explicit cleanup
@@ -15,15 +15,10 @@ is handled automatically by LiteLLM.
import asyncio
import json
import logging
import os
import time
from typing import Any
from litellm.exceptions import Timeout as LiteLLMTimeout
from hindsight_api.config import DEFAULT_LLM_TIMEOUT, ENV_LLM_TIMEOUT
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.llm_trace import LLMResponseUsage, stash_response_usage
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
@@ -31,22 +26,6 @@ from hindsight_api.worker.stage import set_stage
logger = logging.getLogger(__name__)
def _usage_from_litellm_response(response: Any) -> LLMResponseUsage:
"""Extract prompt/completion/cached token counts from a LiteLLM (OpenAI-shaped) usage block."""
usage = getattr(response, "usage", None)
if not usage:
return LLMResponseUsage()
cached_tokens = 0
details = getattr(usage, "prompt_tokens_details", None)
if details:
cached_tokens = getattr(details, "cached_tokens", 0) or 0
return LLMResponseUsage(
input_tokens=getattr(usage, "prompt_tokens", 0) or 0,
output_tokens=getattr(usage, "completion_tokens", 0) or 0,
cached_tokens=cached_tokens,
)
class LiteLLMLLM(LLMInterface):
"""
LLM provider using the LiteLLM SDK for universal model support.
@@ -68,16 +47,12 @@ class LiteLLMLLM(LLMInterface):
base_url: str,
model: str,
reasoning_effort: str = "low",
timeout: float | None = None,
timeout: float = 300.0,
extra_body: dict[str, Any] | None = None,
bedrock_service_tier: str | None = None,
default_headers: dict[str, Any] | None = None,
**kwargs: Any,
):
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
# ``None`` falls back to HINDSIGHT_API_LLM_TIMEOUT, then DEFAULT_LLM_TIMEOUT — never None,
# so the hard ``asyncio.wait_for`` backstop in ``call`` is always bounded.
self.timeout = timeout if timeout is not None else float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT)))
self.timeout = timeout
self._litellm: Any = None
# User-configured extra params merged as top-level kwargs into every
# completion call so LiteLLM normalizes them per-provider (e.g. maps
@@ -85,14 +60,6 @@ class LiteLLMLLM(LLMInterface):
# drops any the target model rejects (litellm.drop_params=True below).
# Sourced from llm_extra_body (env: HINDSIGHT_API_LLM_EXTRA_BODY).
self._extra_body: dict[str, Any] = extra_body or {}
# Operator-configured default headers forwarded to litellm.acompletion as
# ``extra_headers`` (used by deployments routing through proxies / request-
# tracing middleware). Mirrors the Anthropic provider's default_headers
# wiring. Sourced from llm_default_headers (env: HINDSIGHT_API_LLM_DEFAULT_HEADERS).
# Copied so a caller-owned dict can't be mutated through us, and a fresh
# copy is handed to each call below to avoid cross-request contamination.
self._default_headers: dict[str, Any] = dict(default_headers or {})
self.bedrock_service_tier = bedrock_service_tier
try:
import litellm
@@ -108,14 +75,12 @@ class LiteLLMLLM(LLMInterface):
raise RuntimeError("LiteLLM SDK not installed. Run: uv add litellm or pip install litellm") from e
async def verify_connection(self) -> None:
from ...config import get_config
try:
test_messages = [{"role": "user", "content": "test"}]
await self.call(
messages=test_messages,
max_completion_tokens=50,
temperature=get_config().llm_temperature_verification,
temperature=0.0,
scope="verification",
max_retries=0,
)
@@ -154,17 +119,6 @@ class LiteLLMLLM(LLMInterface):
for key, value in self._extra_body.items():
kwargs.setdefault(key, value)
# Forward operator-configured default headers as ``extra_headers`` so they
# reach the provider behind LiteLLM (proxies / request-tracing middleware).
# ``setdefault`` keeps any explicit per-call ``extra_headers`` authoritative;
# a per-call copy prevents LiteLLM/downstream from mutating the stored dict.
if self._default_headers:
kwargs.setdefault("extra_headers", dict(self._default_headers))
# Bedrock service tier: flex (50% cheaper), priority, or reserved
if self.model.startswith("bedrock/") and self.bedrock_service_tier is not None:
kwargs["service_tier"] = self.bedrock_service_tier
return kwargs
# ── per-model output-tokens cap (shared with Router subclass) ────────────
@@ -249,14 +203,7 @@ class LiteLLMLLM(LLMInterface):
if attempt > 0:
set_stage(f"llm.{self._stage_label}.{scope}.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await asyncio.wait_for(
self._acompletion(**call_kwargs),
timeout=self.timeout,
)
# Stash usage before the length check and parse/validate below,
# which may raise locally even though the provider charged for
# these tokens (#2387).
stash_response_usage(_usage_from_litellm_response(response))
response = await self._acompletion(**call_kwargs)
content = response.choices[0].message.content or ""
finish_reason = response.choices[0].finish_reason
@@ -287,9 +234,8 @@ class LiteLLMLLM(LLMInterface):
result = content
# Extract usage
response_usage = _usage_from_litellm_response(response)
input_tokens = response_usage.input_tokens
output_tokens = response_usage.output_tokens
input_tokens = getattr(response.usage, "prompt_tokens", 0) or 0
output_tokens = getattr(response.usage, "completion_tokens", 0) or 0
total_tokens = input_tokens + output_tokens
# Record metrics
@@ -352,25 +298,6 @@ class LiteLLMLLM(LLMInterface):
logger.error(f"LiteLLM returned invalid JSON after {max_retries + 1} attempts")
raise
except (TimeoutError, asyncio.TimeoutError, LiteLLMTimeout) as e:
# litellm/httpx don't always honor their own ``timeout=`` (e.g. a connection held
# open with no token progress), so ``wait_for`` is the hard cap that cancels a hung
# call regardless — otherwise one straggler pins a worker slot and stalls its gather.
last_exception = e
exc_name = type(e).__name__
if attempt < max_retries:
logger.warning(
f"LiteLLM call exceeded timeout={self.timeout}s ({exc_name}, scope={scope}), retrying..."
)
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
logger.error(
f"LiteLLM call timed out after {self.timeout}s on {attempt + 1} attempts "
f"({exc_name}, scope={scope})"
)
raise
except Exception as e:
error_str = str(e).lower()
# Fast fail on auth errors
@@ -421,18 +348,7 @@ class LiteLLMLLM(LLMInterface):
if attempt > 0:
set_stage(f"llm.{self._stage_label}.tools.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await asyncio.wait_for(
self._acompletion(**call_kwargs),
timeout=self.timeout,
)
# Stash usage before the tool-call argument parse below, which
# can raise json.JSONDecodeError locally even though the provider
# already billed for these tokens; without this the error trace
# records 0/0 tokens (#2387). Mirrors call() and the anthropic/
# gemini call_with_tools paths so the litellm tool path (and the
# LiteLLMRouterLLM subclass that inherits this method) completes
# the #2396 usage-on-error coverage.
stash_response_usage(_usage_from_litellm_response(response))
response = await self._acompletion(**call_kwargs)
message = response.choices[0].message
content = message.content
@@ -502,23 +418,6 @@ class LiteLLMLLM(LLMInterface):
output_tokens=output_tokens,
)
except (TimeoutError, asyncio.TimeoutError, LiteLLMTimeout) as e:
# See ``call`` — hard cap so a hung completion cannot block
# forever and pin a worker slot / concurrency permit.
last_exception = e
exc_name = type(e).__name__
if attempt < max_retries:
logger.warning(
f"LiteLLM tool call exceeded timeout={self.timeout}s ({exc_name}, scope={scope}), retrying..."
)
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
continue
logger.error(
f"LiteLLM tool call timed out after {self.timeout}s on {attempt + 1} attempts "
f"({exc_name}, scope={scope})"
)
raise
except Exception as e:
error_str = str(e).lower()
if "401" in error_str or "403" in error_str or "unauthorized" in error_str:
@@ -67,7 +67,7 @@ class LiteLLMRouterLLM(LiteLLMLLM):
model: str,
config: dict[str, Any],
reasoning_effort: str = "low",
timeout: float | None = None,
timeout: float = 300.0,
**kwargs: Any,
):
super().__init__(
@@ -146,28 +146,16 @@ class LiteLLMRouterLLM(LiteLLMLLM):
kwargs["max_completion_tokens"] = self._cap_max_completion_tokens(max_completion_tokens)
if temperature is not None:
kwargs["temperature"] = temperature
# Forward operator-configured default headers as ``extra_headers`` so they
# reach the provider behind the Router (proxies / request-tracing middleware).
# This override deliberately omits api_key/base_url/extra_body (those live in
# the per-deployment Router config), but headers are a cross-cutting operator
# concern, so we inject them here too — mirroring the base provider.
# ``setdefault`` keeps any explicit per-call ``extra_headers`` authoritative;
# a per-call copy prevents LiteLLM/downstream from mutating the stored dict.
if self._default_headers:
kwargs.setdefault("extra_headers", dict(self._default_headers))
return kwargs
async def verify_connection(self) -> None:
from hindsight_api.engine.llm_interface import OutputTooLongError
from ...config import get_config
try:
await self.call(
messages=[{"role": "user", "content": "test"}],
max_completion_tokens=50,
temperature=get_config().llm_temperature_verification,
temperature=0.0,
scope="verification",
max_retries=0,
)
@@ -101,7 +101,7 @@ class MockLLM(LLMInterface):
messages: List of message dicts with 'role' and 'content'.
response_format: Optional Pydantic model for structured output.
max_completion_tokens: Not used in mock.
temperature: Recorded on the call record for test assertions.
temperature: Not used in mock.
scope: Scope identifier for tracking.
max_retries: Not used in mock.
initial_backoff: Not used in mock.
@@ -123,9 +123,6 @@ class MockLLM(LLMInterface):
if response_format and hasattr(response_format, "__name__")
else str(response_format),
"scope": scope,
# Record the temperature so tests can assert per-operation temperature
# wiring (None means the parameter was omitted from the call).
"temperature": temperature,
}
self._mock_calls.append(call_record)
logger.debug(f"Mock LLM call recorded: scope={scope}, model={self.model}")
@@ -211,7 +208,7 @@ class MockLLM(LLMInterface):
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Not used in mock.
temperature: Recorded on the call record for test assertions.
temperature: Not used in mock.
scope: Scope identifier for tracking.
max_retries: Not used in mock.
initial_backoff: Not used in mock.
@@ -228,9 +225,6 @@ class MockLLM(LLMInterface):
"messages": messages,
"tools": [t.get("function", {}).get("name") for t in tools],
"scope": scope,
# Record the temperature so tests can assert per-operation temperature
# wiring (None means the parameter was omitted from the call).
"temperature": temperature,
}
self._mock_calls.append(call_record)
@@ -1,463 +0,0 @@
"""
Native Nous Portal OAuth authentication manager.
The Nous Portal inference endpoint (https://inference-api.nousresearch.com/v1)
speaks the OpenAI-compatible wire format but authenticates with a short-lived,
inference-scoped JWT rather than a static API key. Hermes obtains that JWT once
via an interactive browser login (``hermes portal``) and persists the resulting
OAuth state ``access_token`` + ``refresh_token`` under ``providers.nous`` in
``~/.hermes/auth.json``.
This manager reads that file *directly* and refreshes the access token itself,
exactly mirroring ``codex_auth.py`` (read ``~/.codex/auth.json`` + native
refresh). It deliberately does **not** import the Hermes ``hermes_cli`` package:
that package is the interactive CLI, not a library Hindsight can depend on. The
refresh request shape is mirrored from Hermes' own resolver
(``POST {portal}/api/oauth/token`` with an ``x-nous-refresh-token`` header and a
``grant_type=refresh_token`` form body), so server-side changes affect both
clients identically. The inference bearer is the access token itself in
Hermes' state the ``agent_key`` field is literally ``= access_token``.
Single-use refresh tokens
-------------------------
Nous refresh tokens are single-use with server-side reuse-detection: if two
processes refresh with the same ``refresh_token``, or a rotated token is not
persisted back, the Portal revokes the whole session as a theft signal. Because
Hindsight shares ``~/.hermes/auth.json`` with a possibly-running Hermes agent,
every refresh here is performed while holding the **same cross-process advisory
lock Hermes uses** (``~/.hermes/auth.lock`` via ``fcntl.flock``) and re-reads the
latest ``refresh_token`` from disk under that lock before exchanging it. That is
the protocol Hermes follows too, so the two coordinate safely through the file.
Usage
-----
mgr = NousAuthManager.from_file()
token = mgr.ensure_fresh_token() # proactive; refreshes if near expiry
... # use token as Bearer
mgr.refresh_tokens(force=True) # reactive, on a 401
"""
from __future__ import annotations
import base64
import binascii
import contextlib
import json
import logging
import os
import tempfile
import threading
import time
from collections.abc import Iterator
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import httpx
try:
import fcntl
except ImportError: # pragma: no cover - Windows
fcntl = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants — mirrored from Hermes' canonical Nous resolver
# (hermes_cli/auth.py: DEFAULT_NOUS_* and _refresh_access_token). Endpoints and
# client id are overridable via the same env vars Hermes honours, so a staging
# Portal or a future change can be pointed at without a code change.
# ---------------------------------------------------------------------------
_NOUS_PORTAL_BASE_URL = (
os.environ.get("HERMES_PORTAL_BASE_URL")
or os.environ.get("NOUS_PORTAL_BASE_URL")
or "https://portal.nousresearch.com"
)
_NOUS_INFERENCE_BASE_URL = os.environ.get("NOUS_INFERENCE_BASE_URL") or "https://inference-api.nousresearch.com/v1"
_NOUS_CLIENT_ID = "hermes-cli"
# Proactively refresh this many seconds before the JWT ``exp`` claim — matches
# the 120s skew Hermes' own runtime resolver uses for Nous.
_NOUS_TOKEN_REFRESH_SKEW_SECONDS = 120
# OAuth error codes the Portal returns when the refresh_token itself is no
# longer usable. These are terminal — retrying will not succeed; the user must
# re-run ``hermes portal``.
_NOUS_TERMINAL_REFRESH_ERROR_CODES = frozenset(
{"invalid_grant", "invalid_token", "refresh_token_reused", "refresh_token_expired"}
)
_AUTH_LOCK_TIMEOUT_SECONDS = 20.0
def _default_auth_file() -> Path:
return Path.home() / ".hermes" / "auth.json"
class NousNotLoggedInError(RuntimeError):
"""Raised when ``~/.hermes/auth.json`` has no usable Nous OAuth state.
Remediation: run ``hermes portal`` to log in to Nous Portal.
"""
class NousRefreshExpiredError(RuntimeError):
"""Raised when the Nous refresh_token itself is permanently invalid.
The user must re-run ``hermes portal`` to obtain new credentials. Callers
should surface a clear remediation message and stop retrying.
"""
@contextlib.contextmanager
def _hermes_auth_lock(auth_file: Path, timeout_seconds: float = _AUTH_LOCK_TIMEOUT_SECONDS) -> Iterator[None]:
"""Cross-process advisory lock on the Hermes auth store.
Uses ``<auth_file>.lock`` (i.e. ``~/.hermes/auth.lock``) with
``fcntl.flock(LOCK_EX)`` the exact same lock file and primitive Hermes'
``_auth_store_lock`` takes so a refresh here is mutually exclusive with a
concurrently-running Hermes agent. Degrades to a no-op (with a debug log)
where ``fcntl`` is unavailable (Windows); the single-process in-memory lock
still serialises this process's own refreshes.
"""
if fcntl is None: # pragma: no cover - Windows
logger.debug("fcntl unavailable; Nous refresh proceeds without a cross-process lock.")
yield
return
lock_path = auth_file.with_suffix(".lock")
lock_path.parent.mkdir(parents=True, exist_ok=True)
with open(lock_path, "a+") as lock_file:
deadline = time.monotonic() + max(1.0, timeout_seconds)
while True:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except (BlockingIOError, OSError):
if time.monotonic() >= deadline:
raise TimeoutError("Timed out waiting for the Hermes auth store lock") from None
time.sleep(0.05)
try:
yield
finally:
with contextlib.suppress(OSError):
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
class NousAuthManager:
"""Sync Nous Portal OAuth credential manager.
Holds the access_token + refresh_token in memory and handles
proactive/reactive refresh. A ``threading.Lock`` gives single-flight
semantics within the process; the cross-process ``fcntl`` lock guards
against a concurrent Hermes agent (see module docstring).
"""
def __init__(
self,
access_token: str,
refresh_token: str | None,
auth_file: Path,
*,
portal_base_url: str = _NOUS_PORTAL_BASE_URL,
inference_base_url: str = _NOUS_INFERENCE_BASE_URL,
client_id: str = _NOUS_CLIENT_ID,
) -> None:
self.access_token = access_token
self.refresh_token = refresh_token
self._auth_file = auth_file
self._portal_base_url = portal_base_url.rstrip("/")
self._inference_base_url = inference_base_url.rstrip("/")
self._client_id = client_id
self._lock = threading.Lock()
self._http_client = httpx.Client(timeout=30.0)
# ------------------------------------------------------------------
# Construction
# ------------------------------------------------------------------
@classmethod
def from_file(cls, auth_file: Path | None = None) -> "NousAuthManager":
"""Build a manager from ``providers.nous`` in the Hermes auth store.
Raises
------
NousNotLoggedInError:
If the file is missing, unreadable, or has no Nous OAuth state with
an ``access_token``.
"""
if auth_file is None:
auth_file = _default_auth_file()
if not auth_file.exists():
raise NousNotLoggedInError(
f"Hermes auth file not found: {auth_file}. Run 'hermes portal' to log in to Nous Portal."
)
try:
with open(auth_file) as f:
data = json.load(f)
except (OSError, json.JSONDecodeError) as e:
raise NousNotLoggedInError(f"Could not read Hermes auth file {auth_file}: {type(e).__name__}") from e
state = cls._nous_state(data)
if not state:
raise NousNotLoggedInError(
"Hermes is not logged into Nous Portal (no providers.nous OAuth state). Run 'hermes portal'."
)
access_token = state.get("access_token")
if not isinstance(access_token, str) or not access_token:
raise NousNotLoggedInError("Nous OAuth state has no access_token. Re-authenticate with 'hermes portal'.")
return cls(
access_token=access_token,
refresh_token=state.get("refresh_token"),
auth_file=auth_file,
portal_base_url=cls._optional_url(state.get("portal_base_url")) or _NOUS_PORTAL_BASE_URL,
inference_base_url=cls._optional_url(state.get("inference_base_url")) or _NOUS_INFERENCE_BASE_URL,
client_id=str(state.get("client_id") or _NOUS_CLIENT_ID),
)
@staticmethod
def _nous_state(data: dict[str, Any]) -> dict[str, Any]:
"""Pull the ``providers.nous`` state dict out of a loaded auth store."""
providers = data.get("providers")
if not isinstance(providers, dict):
return {}
state = providers.get("nous")
return state if isinstance(state, dict) else {}
@staticmethod
def _optional_url(value: Any) -> str | None:
return value.rstrip("/") if isinstance(value, str) and value.strip() else None
@property
def base_url(self) -> str:
return self._inference_base_url
# ------------------------------------------------------------------
# Token state
# ------------------------------------------------------------------
@staticmethod
def load_refresh_token_from_file(auth_file: Path) -> str | None:
"""Read ``providers.nous.refresh_token`` from ``auth_file``.
Returns ``None`` when the file is unreadable or omits the field. Does
not raise the caller degrades to using the in-memory token.
"""
try:
with open(auth_file) as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return None
return NousAuthManager._nous_state(data).get("refresh_token")
@staticmethod
def _decode_jwt_exp_unixtime(token: str) -> int | None:
"""Return the JWT ``exp`` claim as a unix timestamp, or None on failure.
The signature is not verified the server is the source of truth on
acceptance. This only schedules proactive refresh.
"""
try:
parts = token.split(".")
if len(parts) < 2:
return None
payload_b64 = parts[1]
padding = "=" * (-len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding).decode("utf-8"))
exp = payload.get("exp")
return int(exp) if exp is not None else None
except (ValueError, TypeError, json.JSONDecodeError, binascii.Error):
return None
def _token_is_stale(self, skew_seconds: int = _NOUS_TOKEN_REFRESH_SKEW_SECONDS) -> bool:
"""True when the cached access_token is past expiry (with skew).
Returns False when expiry cannot be determined we'd rather use a
possibly-expired token and recover via the reactive 401 path than
refresh aggressively on every request when ``exp`` is unparseable.
"""
exp = self._decode_jwt_exp_unixtime(self.access_token)
if exp is None:
return False
return exp <= int(time.time()) + skew_seconds
# ------------------------------------------------------------------
# Persistence
# ------------------------------------------------------------------
def _persist_state_atomic(self, updated: dict[str, Any]) -> None:
"""Patch ``providers.nous`` in ``_auth_file`` and write atomically.
Re-reads the on-disk store first so fields written by Hermes (other
providers, the credential pool, rotated tokens) are never clobbered,
then patches only the Nous OAuth fields and ``os.replace``s into place
(atomic on POSIX within the same filesystem). Must be called while
holding :func:`_hermes_auth_lock`.
"""
try:
with open(self._auth_file) as f:
loaded = json.load(f)
current: dict[str, Any] = loaded if isinstance(loaded, dict) else {}
except (OSError, json.JSONDecodeError):
current = {}
providers = current.get("providers")
if not isinstance(providers, dict):
providers = {}
current["providers"] = providers
state = providers.get("nous")
if not isinstance(state, dict):
state = {}
providers["nous"] = state
state.update(updated)
# The inference bearer is the access token itself; keep agent_key in
# sync so Hermes' own resolver/status sees the rotation too.
state["agent_key"] = updated.get("access_token", state.get("access_token"))
current["updated_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
parent = self._auth_file.parent
parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(prefix=".auth.", suffix=".json.tmp", dir=str(parent))
try:
with os.fdopen(fd, "w") as f:
json.dump(current, f, indent=2)
f.flush()
os.fsync(f.fileno())
with contextlib.suppress(OSError):
os.chmod(tmp_path, 0o600)
os.replace(tmp_path, self._auth_file)
except Exception:
with contextlib.suppress(OSError):
os.unlink(tmp_path)
raise
# ------------------------------------------------------------------
# Refresh
# ------------------------------------------------------------------
@staticmethod
def _extract_oauth_error_code(response: httpx.Response) -> str | None:
"""Pull the OAuth error code out of a 4xx refresh response, if present."""
try:
body = response.json()
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(body, dict):
return None
err = body.get("error")
if isinstance(err, str):
return err
if isinstance(err, dict) and isinstance(err.get("code"), str):
return err["code"]
code = body.get("error_code")
return code if isinstance(code, str) else None
def refresh_tokens(self, reason: str = "", *, force: bool = False) -> None:
"""Single-flight Nous OAuth token refresh.
Serialised through ``self._lock`` (in-process single-flight) and
:func:`_hermes_auth_lock` (cross-process, vs a running Hermes agent).
The latest ``refresh_token`` is re-read from disk under the lock before
the exchange single-use tokens make using a stale in-memory RT a
session-revoking mistake.
Raises
------
NousRefreshExpiredError:
On a terminal refresh error (expired/reused/invalid grant).
RuntimeError:
For other refresh failures (network, 5xx, missing refresh_token).
"""
token_before_lock = self.access_token
with self._lock:
if force:
if self.access_token != token_before_lock:
return # another caller already refreshed while we waited
elif not self._token_is_stale():
return
with _hermes_auth_lock(self._auth_file):
# Re-read the freshest refresh_token persisted by whoever rotated
# last (this process or Hermes). Using a stale RT is exactly what
# trips the Portal's single-use reuse-detection.
disk_rt = self.load_refresh_token_from_file(self._auth_file)
if disk_rt:
self.refresh_token = disk_rt
if not self.refresh_token:
raise RuntimeError(
"Nous access_token is expired but no refresh_token is available. "
"Run 'hermes portal' to re-authenticate."
)
log_reason = f" ({reason})" if reason else ""
logger.info(f"Refreshing Nous Portal access_token{log_reason}")
try:
response = self._http_client.post(
f"{self._portal_base_url}/api/oauth/token",
headers={"x-nous-refresh-token": self.refresh_token},
data={"grant_type": "refresh_token", "client_id": self._client_id},
timeout=30.0,
)
except httpx.RequestError as e:
raise RuntimeError(f"Nous OAuth refresh network error: {type(e).__name__}") from e
if response.status_code != 200:
code = self._extract_oauth_error_code(response)
if code in _NOUS_TERMINAL_REFRESH_ERROR_CODES or response.status_code in (400, 401):
raise NousRefreshExpiredError(
f"Nous refresh_token is no longer valid (status={response.status_code}, "
f"error={code or 'none'}). Run 'hermes portal' to re-authenticate."
)
raise RuntimeError(f"Nous OAuth refresh failed with HTTP {response.status_code}")
try:
body = response.json()
except (json.JSONDecodeError, ValueError) as e:
raise RuntimeError(f"Nous OAuth refresh returned non-JSON body: {e}") from e
new_access = body.get("access_token")
if not new_access:
raise RuntimeError("Nous OAuth refresh returned no access_token")
new_refresh = body.get("refresh_token") or self.refresh_token
# Update in-memory state first so waiters see fresh credentials
# even if the disk write fails.
self.access_token = new_access
self.refresh_token = new_refresh
persisted: dict[str, Any] = {"access_token": new_access, "refresh_token": new_refresh}
expires_in = body.get("expires_in")
if isinstance(expires_in, (int, float)):
persisted["expires_at"] = datetime.fromtimestamp(
time.time() + float(expires_in), tz=timezone.utc
).isoformat()
try:
self._persist_state_atomic(persisted)
except OSError as e:
logger.warning(
f"Nous refresh succeeded but persisting auth.json failed: {type(e).__name__}. "
"In-memory credentials are current; the on-disk rotated token was not saved."
)
logger.info("Nous Portal access_token refreshed successfully")
def ensure_fresh_token(self) -> str:
"""Refresh proactively if near/at expiry, then return the bearer token.
Cheap when fresh (a JWT exp decode + comparison).
"""
if self._token_is_stale():
self.refresh_tokens(reason="proactive (token near expiry)")
return self.access_token
def close(self) -> None:
"""Close the underlying HTTP client."""
self._http_client.close()
@@ -1,167 +0,0 @@
"""
Nous Portal LLM provider for Hindsight.
Thin wrapper over :class:`OpenAICompatibleLLM`. The Nous Portal speaks the
OpenAI chat-completions wire format, so all request/response handling is
inherited unchanged. The only thing Nous needs on top is a rotating,
inference-scoped JWT (there is no static API key in the Hermes login flow),
which :class:`NousAuthManager` reads from ``~/.hermes/auth.json`` and refreshes
natively the same pattern as the Codex provider, with no dependency on the
``hermes_cli`` package. See ``nous_auth.py`` for the auth mechanics.
Configure with::
llm_provider = "nous"
llm_base_url = "https://inference-api.nousresearch.com/v1" # or omit
llm_model = "deepseek/deepseek-v4-flash" # any Nous slug
No API key is set in config; the token comes from the shared Hermes auth store
after a one-time ``hermes portal`` login.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from openai import APIStatusError, AsyncOpenAI
from hindsight_api.engine.providers.nous_auth import (
NousAuthManager,
NousNotLoggedInError,
NousRefreshExpiredError,
)
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
logger = logging.getLogger(__name__)
__all__ = ["NousLLM", "NousAuthManager", "NousNotLoggedInError", "NousRefreshExpiredError"]
class NousLLM(OpenAICompatibleLLM):
"""OpenAI-compatible provider for the Nous Portal with rotating-JWT auth."""
def __init__(
self,
provider: str,
api_key: str, # Ignored — the token is read from ~/.hermes/auth.json
base_url: str,
model: str,
reasoning_effort: str = "low",
**kwargs: Any,
):
try:
self._auth = NousAuthManager.from_file()
except NousNotLoggedInError as e:
raise RuntimeError(
f"Failed to load Nous Portal credentials: {e}\n\n"
"To set up Nous authentication:\n"
"1. Install Hermes: https://hermes-agent.nousresearch.com\n"
"2. Log in to Nous Portal: hermes portal\n"
"3. Verify: hermes portal status\n\n"
"Or use a different provider (openai, anthropic, gemini) with an API key."
) from e
# Single-flight async refresh lock — concurrent coroutines racing toward
# an expired token produce one network refresh.
self._auth_lock = asyncio.Lock()
token = self._auth.access_token
resolved_base = base_url or self._auth.base_url
# Parent validates provider against a fixed list; present as "openai"
# (identical wire format) while retaining the true identity for logs.
super().__init__(
provider="openai",
api_key=token,
base_url=resolved_base,
model=model,
reasoning_effort=reasoning_effort,
**kwargs,
)
self._nous_provider_name = provider
logger.info(
"Nous LLM initialized: model=%s base_url=%s (rotating inference:invoke JWT)",
self.model,
self.base_url,
)
# ------------------------------------------------------------------
# Token lifecycle
# ------------------------------------------------------------------
def _rebuild_client(self) -> None:
"""Rebuild the OpenAI SDK client against the current token."""
self.api_key = self._auth.access_token
self._client = AsyncOpenAI(
api_key=self.api_key,
base_url=self.base_url,
max_retries=0,
timeout=self.timeout,
)
async def _ensure_fresh_token(self) -> None:
"""Proactively refresh if the JWT is near expiry; rebuild on change.
Cheap when fresh (a JWT exp decode). The blocking refresh (network +
cross-process file lock) is offloaded to a thread so the event loop is
never stalled.
"""
if not self._auth._token_is_stale():
return
await self._refresh(reason="proactive (token near expiry)", force=False)
async def _refresh(self, *, reason: str, force: bool) -> None:
token_before = self.api_key
async with self._auth_lock:
if force:
if self.api_key != token_before:
return # another coroutine already refreshed
elif not self._auth._token_is_stale():
return
await asyncio.to_thread(lambda: self._auth.refresh_tokens(reason, force=force))
if self._auth.access_token != self.api_key:
self._rebuild_client()
async def _with_auth_retry(self, fn: Any, label: str, *args: Any, **kwargs: Any) -> Any:
"""Run an OpenAI-compatible call, refreshing once on a 401.
The proactive refresh covers most expiries; a token can still be
rejected mid-flight if Hermes rotated it out from under us or the exp
claim was unparseable. One reactive refresh + retry is the safety net.
"""
await self._ensure_fresh_token()
try:
return await fn(*args, **kwargs)
except APIStatusError as e:
if getattr(e, "status_code", None) != 401:
raise
logger.warning("Nous 401 (%s) — forcing token refresh and retrying once.", label)
try:
await self._refresh(reason=f"reactive (HTTP 401 on {label})", force=True)
except NousRefreshExpiredError as refresh_err:
raise RuntimeError(
"Nous authentication failed and the refresh_token is no longer valid.\n"
"Run 'hermes portal' to re-authenticate."
) from refresh_err
return await fn(*args, **kwargs)
# ------------------------------------------------------------------
# Overrides
# ------------------------------------------------------------------
async def verify_connection(self) -> None:
await self._ensure_fresh_token()
return await super().verify_connection()
async def call(self, *args: Any, **kwargs: Any) -> Any:
return await self._with_auth_retry(super().call, "call", *args, **kwargs)
async def call_with_tools(self, *args: Any, **kwargs: Any) -> Any:
return await self._with_auth_retry(super().call_with_tools, "call_with_tools", *args, **kwargs)
async def cleanup(self) -> None:
self._auth.close()
parent_cleanup = getattr(super(), "cleanup", None)
if parent_cleanup is not None:
await parent_cleanup()
@@ -26,8 +26,6 @@ import logging
import os
import re
import time
from datetime import UTC, datetime, timedelta
from email.utils import parsedate_to_datetime
from typing import Any
from urllib.parse import parse_qs, urlparse, urlunparse
@@ -35,9 +33,7 @@ import httpx
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
from hindsight_api.config import DEFAULT_LLM_TIMEOUT, ENV_LLM_TIMEOUT
from hindsight_api.engine.bank_attribution import apply_bank_attribution
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError, ProviderRateLimitResetError
from hindsight_api.engine.llm_trace import LLMResponseUsage, stash_response_usage
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
@@ -86,49 +82,6 @@ def _strip_code_fences(content: str) -> str:
return content
# Reasoning/thinking tags emitted by extended-thinking models. Some providers
# (e.g. MiniMax-M3) leak the chain-of-thought wrapped in these tags into the
# response body instead of a separate reasoning_content field. Each entry is
# (open_tag, close_tag); the open tag also matches when the close tag is missing
# (truncated output) so a dangling block is removed to end-of-string.
_REASONING_TAG_PAIRS: tuple[tuple[str, str], ...] = (
("<think>", "</think>"),
("<thinking>", "</thinking>"),
("<thought>", "</thought>"),
("<reasoning>", "</reasoning>"),
("|startthink|", "|endthink|"),
)
def _strip_reasoning_tags(text: str) -> str:
"""Strip extended-thinking/reasoning blocks from an LLM response.
Removes the full set of tag styles emitted by reasoning models:
``<think>``, ``<thinking>``, ``<thought>``, ``<reasoning>`` and the
``|startthink|...|endthink|`` markers. Both the structured (JSON) path and
the free-form path must call this otherwise a non-structured response
(e.g. a mental-model markdown blob from MiniMax-M3) leaks the raw
``<think>...</think>`` verbatim into stored memories.
Handles two cases:
1. Closed blocks: ``<think>...</think>`` removed wherever they appear.
2. Unclosed blocks: a dangling ``<think>`` with no closing tag (model output
truncated mid-thought) is removed from the open tag to end-of-string.
Returns the input unchanged (modulo surrounding whitespace) when no tags are
present.
"""
if not text:
return text
for open_tag, close_tag in _REASONING_TAG_PAIRS:
open_re = re.escape(open_tag)
close_re = re.escape(close_tag)
# Closed blocks first, then any remaining unclosed (truncated) block.
text = re.sub(rf"{open_re}.*?{close_re}", "", text, flags=re.DOTALL)
text = re.sub(rf"{open_re}.*", "", text, flags=re.DOTALL)
return text.strip()
def _response_get(response: Any, key: str, default: Any = None) -> Any:
if isinstance(response, dict):
return response.get(key, default)
@@ -233,21 +186,6 @@ def _content_or_error(response: Any, *, provider: str, model: str, scope: str) -
return content, choice
def _usage_from_openai_response(response: Any) -> LLMResponseUsage:
"""Extract prompt/completion/cached token counts from an OpenAI-shaped usage block."""
usage = getattr(response, "usage", None)
input_tokens = (usage.prompt_tokens or 0) if usage else 0
output_tokens = (usage.completion_tokens or 0) if usage else 0
cached_tokens = 0
if usage and getattr(usage, "prompt_tokens_details", None):
cached_tokens = getattr(usage.prompt_tokens_details, "cached_tokens", 0) or 0
return LLMResponseUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
cached_tokens=cached_tokens,
)
def _ensure_json_word_in_user_message(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Some OpenAI-compatible gateways require 'json' in a user message for json_object mode."""
@@ -295,122 +233,6 @@ def _summarize_status_error(e: APIStatusError, body_max: int = 400) -> str:
return f"HTTP {e.status_code}: {body_str or '<no body>'}"
_RATE_LIMIT_RESET_AT_RE = re.compile(
r"\breset at\s+"
r"(?P<reset_at>\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\s*(?:Z|[+-]\d{2}:?\d{2}))?)",
re.IGNORECASE,
)
_RATE_LIMIT_WINDOW_RE = re.compile(
r"\b(?:for|in)\s+(?P<amount>\d+)\s*(?P<unit>second|minute|hour|day)s?\b",
re.IGNORECASE,
)
def _status_error_body_text(e: APIStatusError) -> str:
body: Any = getattr(e, "body", None)
if body is None:
try:
body = e.response.text
except Exception:
body = None
if isinstance(body, (dict, list)):
try:
return json.dumps(body, default=str, ensure_ascii=False)
except Exception:
return str(body)
return str(body or "").strip()
def _parse_retry_after_header(value: str | None, now: datetime) -> datetime | None:
if not value:
return None
raw = value.strip()
try:
seconds = float(raw)
except ValueError:
seconds = -1.0
if seconds >= 0:
return now + timedelta(seconds=seconds)
try:
parsed = parsedate_to_datetime(raw)
except (TypeError, ValueError, IndexError, OverflowError):
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
def _parse_reset_at_datetime(value: str) -> datetime | None:
raw = value.strip().replace(" ", "T")
if raw.endswith("Z"):
raw = f"{raw[:-1]}+00:00"
elif re.search(r"[+-]\d{4}$", raw):
raw = f"{raw[:-2]}:{raw[-2:]}"
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
if parsed.tzinfo is None:
# Some providers (z.ai included) return a wall-clock reset timestamp
# without a zone. Interpret it in the host's local zone so logs, status
# pages, and the queued next_retry_at describe the same operator-facing
# clock instead of silently shifting by UTC offset.
parsed = parsed.astimezone()
return parsed.astimezone(UTC)
def _rate_limit_retry_at(e: APIStatusError) -> datetime | None:
now = datetime.now(UTC)
response = getattr(e, "response", None)
headers = getattr(response, "headers", None)
if headers is not None:
retry_at = _parse_retry_after_header(headers.get("retry-after") or headers.get("Retry-After"), now)
if retry_at is not None and retry_at > now:
return retry_at
body_text = _status_error_body_text(e)
reset_match = _RATE_LIMIT_RESET_AT_RE.search(body_text)
if reset_match:
retry_at = _parse_reset_at_datetime(reset_match.group("reset_at"))
if retry_at is not None and retry_at > now:
return retry_at
window_match = _RATE_LIMIT_WINDOW_RE.search(body_text)
if not window_match:
return None
amount = int(window_match.group("amount"))
unit = window_match.group("unit").lower()
if unit == "second":
seconds = amount
elif unit == "minute":
seconds = amount * 60
elif unit == "hour":
seconds = amount * 3600
else:
seconds = amount * 86400
return now + timedelta(seconds=seconds)
def _raise_provider_quota_defer(
e: APIStatusError, *, provider: str, model: str, scope: str, max_backoff: float
) -> None:
if e.status_code != 429:
return
retry_at = _rate_limit_retry_at(e)
if retry_at is None:
return
if (retry_at - datetime.now(UTC)).total_seconds() <= max_backoff:
return
summary = _summarize_status_error(e)
raise ProviderRateLimitResetError(
retry_at=retry_at,
message=(
f"Provider quota exhausted ({provider}/{model}, scope={scope}); retry at {retry_at.isoformat()}: {summary}"
),
) from e
class OpenAICompatibleLLM(LLMInterface):
"""
LLM provider for OpenAI-compatible APIs.
@@ -446,7 +268,7 @@ class OpenAICompatibleLLM(LLMInterface):
base_url: Base URL for the API (uses defaults for groq/ollama/lmstudio if empty).
model: Model name.
reasoning_effort: Reasoning effort level for supported models ("low", "medium", "high").
timeout: Request timeout in seconds (uses env var or 120s default).
timeout: Request timeout in seconds (uses env var or 300s default).
groq_service_tier: Groq service tier ("on_demand", "flex", "auto").
extra_body: Extra body params merged into every API call.
**kwargs: Additional provider-specific parameters.
@@ -465,10 +287,8 @@ class OpenAICompatibleLLM(LLMInterface):
"deepseek",
"volcano",
"openrouter",
"requesty",
"zai",
"opencode-go",
"atlas",
"fireworks",
]
if self.provider not in valid_providers:
@@ -490,14 +310,10 @@ class OpenAICompatibleLLM(LLMInterface):
self.base_url = "https://api.deepseek.com"
elif self.provider == "openrouter":
self.base_url = "https://openrouter.ai/api/v1"
elif self.provider == "requesty":
self.base_url = "https://router.requesty.ai/v1"
elif self.provider == "zai":
self.base_url = "https://api.z.ai/api/coding/paas/v4"
elif self.provider == "opencode-go":
self.base_url = "https://opencode.ai/zen/go/v1"
elif self.provider == "atlas":
self.base_url = "https://api.atlascloud.ai/v1"
elif self.provider == "fireworks":
# OpenAI-compatible inference host (online path). The batch API
# lives on a separate control-plane host — see FireworksLLM.
@@ -516,10 +332,8 @@ class OpenAICompatibleLLM(LLMInterface):
"minimax",
"deepseek",
"openrouter",
"requesty",
"zai",
"opencode-go",
"atlas",
"ollama-cloud",
)
and not self.api_key
@@ -781,8 +595,6 @@ class OpenAICompatibleLLM(LLMInterface):
call_params["messages"] = _ensure_json_word_in_user_message(call_params["messages"])
call_params["response_format"] = {"type": "json_object"}
apply_bank_attribution(call_params)
last_exception = None
for attempt in range(max_retries + 1):
@@ -794,9 +606,6 @@ class OpenAICompatibleLLM(LLMInterface):
try:
if response_format is not None:
response = await self._client.chat.completions.create(**call_params)
# Stash usage before parse/validate, which may raise locally
# even though the provider charged for these tokens (#2387).
stash_response_usage(_usage_from_openai_response(response))
content, first_choice = _content_or_error(
response,
@@ -805,10 +614,15 @@ class OpenAICompatibleLLM(LLMInterface):
scope=scope,
)
# Strip reasoning model thinking tags (closed and unclosed).
# Strip reasoning model thinking tags
# Supports: <think>, <thinking>, <thought>, <reasoning>, |startthink|/|endthink|
original_len = len(content)
content = _strip_reasoning_tags(content)
content = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL)
content = re.sub(r"<thinking>.*?</thinking>", "", content, flags=re.DOTALL)
content = re.sub(r"<thought>.*?</thought>", "", content, flags=re.DOTALL)
content = re.sub(r"<reasoning>.*?</reasoning>", "", content, flags=re.DOTALL)
content = re.sub(r"\|startthink\|.*?\|endthink\|", "", content, flags=re.DOTALL)
content = content.strip()
if len(content) < original_len:
logger.debug(f"Stripped {original_len - len(content)} chars of reasoning tokens")
@@ -850,7 +664,6 @@ class OpenAICompatibleLLM(LLMInterface):
result = response_format.model_validate(json_data)
else:
response = await self._client.chat.completions.create(**call_params)
stash_response_usage(_usage_from_openai_response(response))
result, first_choice = _content_or_error(
response,
provider=self.provider,
@@ -858,33 +671,15 @@ class OpenAICompatibleLLM(LLMInterface):
scope=scope,
)
# Free-form (non-structured) output also leaks reasoning tags:
# reasoning models like MiniMax-M3 wrap their chain-of-thought
# in <think>...</think> in the response body. Without this strip
# a mental-model markdown blob is stored verbatim with the raw
# thinking tags. Mirrors the structured-output path above.
result = _strip_reasoning_tags(result)
# Record token usage metrics
duration = time.time() - start_time
usage = response.usage
response_usage = _usage_from_openai_response(response)
input_tokens = response_usage.input_tokens
output_tokens = response_usage.output_tokens
input_tokens = usage.prompt_tokens or 0 if usage else 0
output_tokens = usage.completion_tokens or 0 if usage else 0
total_tokens = usage.total_tokens or 0 if usage else 0
cached_tokens = response_usage.cached_tokens
thoughts_tokens = 0
if usage and getattr(usage, "completion_tokens_details", None):
thoughts_tokens = getattr(usage.completion_tokens_details, "reasoning_tokens", 0) or 0
# OpenAI-compatible providers fold reasoning tokens into
# ``completion_tokens`` (and thus ``total_tokens``), but the
# TokenUsage contract — and the Gemini provider — treat
# ``output_tokens``/``total_tokens`` as visible-only, surfacing
# reasoning separately in ``thoughts_tokens``. Subtract so the
# two fields don't double-count reasoning (cost over-attribution).
if thoughts_tokens:
output_tokens = max(0, output_tokens - thoughts_tokens)
total_tokens = max(0, total_tokens - thoughts_tokens)
cached_tokens = 0
if usage and getattr(usage, "prompt_tokens_details", None):
cached_tokens = getattr(usage.prompt_tokens_details, "cached_tokens", 0) or 0
# Record LLM metrics
metrics = get_metrics_collector()
@@ -933,7 +728,6 @@ class OpenAICompatibleLLM(LLMInterface):
output_tokens=output_tokens,
total_tokens=total_tokens,
cached_tokens=cached_tokens,
thoughts_tokens=thoughts_tokens,
)
return result, token_usage
return result
@@ -964,10 +758,6 @@ class OpenAICompatibleLLM(LLMInterface):
logger.error(f"Auth error (HTTP {e.status_code}), not retrying: {str(e)}")
raise
_raise_provider_quota_defer(
e, provider=self.provider, model=self.model, scope=scope, max_backoff=max_backoff
)
# Handle tool_use_failed error - model outputted in tool call format
if e.status_code == 400 and response_format is not None:
try:
@@ -1021,6 +811,7 @@ class OpenAICompatibleLLM(LLMInterface):
f"scope={scope}): {_summarize_status_error(e)}"
)
raise
except ProviderResponseError as e:
last_exception = e
if e.retryable and attempt < max_retries:
@@ -1154,8 +945,6 @@ class OpenAICompatibleLLM(LLMInterface):
if extra_body:
call_params["extra_body"] = extra_body
apply_bank_attribution(call_params)
last_exception = None
for attempt in range(max_retries + 1):
@@ -1184,17 +973,6 @@ class OpenAICompatibleLLM(LLMInterface):
usage = response.usage
input_tokens = usage.prompt_tokens or 0 if usage else 0
output_tokens = usage.completion_tokens or 0 if usage else 0
cached_tokens = 0
if usage and getattr(usage, "prompt_tokens_details", None):
cached_tokens = getattr(usage.prompt_tokens_details, "cached_tokens", 0) or 0
thoughts_tokens = 0
if usage and getattr(usage, "completion_tokens_details", None):
thoughts_tokens = getattr(usage.completion_tokens_details, "reasoning_tokens", 0) or 0
# See ``call()``: OpenAI-compatible ``completion_tokens`` includes
# reasoning, so make ``output_tokens`` visible-only to avoid
# double-counting it against ``thoughts_tokens``.
if thoughts_tokens:
output_tokens = max(0, output_tokens - thoughts_tokens)
metrics = get_metrics_collector()
metrics.record_llm_call(
@@ -1237,8 +1015,6 @@ class OpenAICompatibleLLM(LLMInterface):
finish_reason=finish_reason,
input_tokens=input_tokens,
output_tokens=output_tokens,
cached_tokens=cached_tokens,
thoughts_tokens=thoughts_tokens,
)
except APIConnectionError as e:
@@ -1266,10 +1042,6 @@ class OpenAICompatibleLLM(LLMInterface):
f"not retrying: {_summarize_status_error(e)}"
)
raise
_raise_provider_quota_defer(
e, provider=self.provider, model=self.model, scope=scope, max_backoff=max_backoff
)
last_exception = e
if attempt < max_retries:
logger.warning(
@@ -1283,6 +1055,7 @@ class OpenAICompatibleLLM(LLMInterface):
f"({self.provider}/{self.model}, scope={scope}): {_summarize_status_error(e)}"
)
raise
except Exception:
raise
@@ -6,17 +6,12 @@ structured information like temporal constraints.
"""
import logging
import re
from abc import ABC, abstractmethod
from datetime import datetime, timedelta
from pydantic import BaseModel, Field
from hindsight_api.engine.temporal_periods import (
NO_TEMPORAL_CONSTRAINT,
extract_period,
is_embedded_cjk_dateparser_match,
)
logger = logging.getLogger(__name__)
@@ -128,12 +123,9 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
# Check for period expressions first (these need special handling)
query_lower = query.lower()
period_result = extract_period(query_lower, reference_date)
if period_result is NO_TEMPORAL_CONSTRAINT:
return QueryAnalysis(temporal_constraint=None)
if isinstance(period_result, tuple):
start_date, end_date = period_result
return QueryAnalysis(temporal_constraint=TemporalConstraint(start_date=start_date, end_date=end_date))
period_result = self._extract_period(query_lower, reference_date)
if period_result is not None:
return QueryAnalysis(temporal_constraint=period_result)
# Lazy load dateparser (only imports on first call, then cached)
self.load()
@@ -166,12 +158,7 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
# Filter out false positives (common words parsed as dates)
false_positives = {"do", "may", "march", "will", "can", "sat", "sun", "mon", "tue", "wed", "thu", "fri"}
valid_results = [
(text, date)
for text, date in results
if (text.lower() not in false_positives or len(text) > 3)
and not is_embedded_cjk_dateparser_match(query, text)
]
valid_results = [(text, date) for text, date in results if text.lower() not in false_positives or len(text) > 3]
if not valid_results:
return QueryAnalysis(temporal_constraint=None)
@@ -185,6 +172,127 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
return QueryAnalysis(temporal_constraint=TemporalConstraint(start_date=start_date, end_date=end_date))
def _extract_period(self, query: str, reference_date: datetime) -> TemporalConstraint | None:
"""
Extract period-based temporal expressions (week, month, year, weekend).
These need special handling as they represent date ranges, not single dates.
Supports multiple languages.
"""
def constraint(start: datetime, end: datetime) -> TemporalConstraint:
return TemporalConstraint(
start_date=start.replace(hour=0, minute=0, second=0, microsecond=0),
end_date=end.replace(hour=23, minute=59, second=59, microsecond=999999),
)
# Yesterday patterns (English, Spanish, Italian, French, German)
if re.search(r"\b(yesterday|ayer|ieri|hier|gestern)\b", query, re.IGNORECASE):
d = reference_date - timedelta(days=1)
return constraint(d, d)
# Today patterns
if re.search(r"\b(today|hoy|oggi|aujourd\'?hui|heute)\b", query, re.IGNORECASE):
return constraint(reference_date, reference_date)
# "a couple of days ago" / "a few days ago" patterns
# These are imprecise so we create a range
if re.search(r"\b(a\s+)?couple\s+(of\s+)?days?\s+ago\b", query, re.IGNORECASE):
# "a couple of days" = approximately 2 days, give range of 1-3 days
return constraint(reference_date - timedelta(days=3), reference_date - timedelta(days=1))
if re.search(r"\b(a\s+)?few\s+days?\s+ago\b", query, re.IGNORECASE):
# "a few days" = approximately 3-4 days, give range of 2-5 days
return constraint(reference_date - timedelta(days=5), reference_date - timedelta(days=2))
# "a couple of weeks ago" / "a few weeks ago" patterns
if re.search(r"\b(a\s+)?couple\s+(of\s+)?weeks?\s+ago\b", query, re.IGNORECASE):
# "a couple of weeks" = approximately 2 weeks, give range of 1-3 weeks
return constraint(reference_date - timedelta(weeks=3), reference_date - timedelta(weeks=1))
if re.search(r"\b(a\s+)?few\s+weeks?\s+ago\b", query, re.IGNORECASE):
# "a few weeks" = approximately 3-4 weeks, give range of 2-5 weeks
return constraint(reference_date - timedelta(weeks=5), reference_date - timedelta(weeks=2))
# "a couple of months ago" / "a few months ago" patterns
if re.search(r"\b(a\s+)?couple\s+(of\s+)?months?\s+ago\b", query, re.IGNORECASE):
# "a couple of months" = approximately 2 months, give range of 1-3 months
return constraint(reference_date - timedelta(days=90), reference_date - timedelta(days=30))
if re.search(r"\b(a\s+)?few\s+months?\s+ago\b", query, re.IGNORECASE):
# "a few months" = approximately 3-4 months, give range of 2-5 months
return constraint(reference_date - timedelta(days=150), reference_date - timedelta(days=60))
# Last week patterns (English, Spanish, Italian, French, German)
if re.search(
r"\b(last\s+week|la\s+semana\s+pasada|la\s+settimana\s+scorsa|la\s+semaine\s+derni[eè]re|letzte\s+woche)\b",
query,
re.IGNORECASE,
):
start = reference_date - timedelta(days=reference_date.weekday() + 7)
return constraint(start, start + timedelta(days=6))
# Last month patterns
if re.search(
r"\b(last\s+month|el\s+mes\s+pasado|il\s+mese\s+scorso|le\s+mois\s+dernier|letzten?\s+monat)\b",
query,
re.IGNORECASE,
):
first = reference_date.replace(day=1)
end = first - timedelta(days=1)
start = end.replace(day=1)
return constraint(start, end)
# Last year patterns
if re.search(
r"\b(last\s+year|el\s+a[ñn]o\s+pasado|l\'anno\s+scorso|l\'ann[ée]e\s+derni[eè]re|letztes?\s+jahr)\b",
query,
re.IGNORECASE,
):
year = reference_date.year - 1
return constraint(datetime(year, 1, 1), datetime(year, 12, 31))
# Last weekend patterns
if re.search(
r"\b(last\s+weekend|el\s+fin\s+de\s+semana\s+pasado|lo\s+scorso\s+fine\s+settimana|le\s+week-?end\s+dernier|letztes?\s+wochenende)\b",
query,
re.IGNORECASE,
):
days_since_sat = (reference_date.weekday() + 2) % 7
if days_since_sat == 0:
days_since_sat = 7
sat = reference_date - timedelta(days=days_since_sat)
return constraint(sat, sat + timedelta(days=1))
# Month + Year patterns (e.g., "June 2024", "junio 2024", "giugno 2024")
month_patterns = {
"january|enero|gennaio|janvier|januar": 1,
"february|febrero|febbraio|f[ée]vrier|februar": 2,
"march|marzo|mars|m[äa]rz": 3,
"april|abril|aprile|avril": 4,
"may|mayo|maggio|mai": 5,
"june|junio|giugno|juin|juni": 6,
"july|julio|luglio|juillet|juli": 7,
"august|agosto|ao[uû]t": 8,
"september|septiembre|settembre|septembre": 9,
"october|octubre|ottobre|octobre|oktober": 10,
"november|noviembre|novembre": 11,
"december|diciembre|dicembre|d[ée]cembre|dezember": 12,
}
for pattern, month_num in month_patterns.items():
match = re.search(rf"\b({pattern})\s+(\d{{4}})\b", query, re.IGNORECASE)
if match:
year = int(match.group(2))
start = datetime(year, month_num, 1)
if month_num == 12:
end = datetime(year, 12, 31)
else:
end = datetime(year, month_num + 1, 1) - timedelta(days=1)
return constraint(start, end)
return None
class TransformerQueryAnalyzer(QueryAnalyzer):
"""
@@ -14,8 +14,7 @@ import re
import time
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from ...config import get_config
from .models import DirectiveInfo, LLMCall, ReflectAgentResult, StructuredOutputResult, TokenUsageSummary, ToolCall
from .models import DirectiveInfo, LLMCall, ReflectAgentResult, TokenUsageSummary, ToolCall
from .prompts import (
_extract_directive_rules,
build_final_prompt,
@@ -90,87 +89,12 @@ _LEAKED_JSON_SUFFIX = re.compile(
r'\s*```(?:json)?\s*\{[^}]*(?:"(?:observation_ids|memory_ids|mental_model_ids)"|\})\s*```\s*$',
re.DOTALL | re.IGNORECASE,
)
_LEAKED_JSON_OBJECT = re.compile(
r'\s*\{[^{]*"(?:observation_ids|memory_ids|mental_model_ids|answer)"[^}]*\}\s*$', re.DOTALL
)
_TRAILING_IDS_PATTERN = re.compile(
r"\s*(?:observation_ids|memory_ids|mental_model_ids)\s*[=:]\s*\[.*?\]\s*$", re.DOTALL | re.IGNORECASE
)
_JSON_CODE_FENCE_PATTERN = re.compile(r"^\s*```(?:json)?\s*(\{.*\})\s*```\s*$", re.DOTALL | re.IGNORECASE)
_DONE_ARGUMENT_KEYS = frozenset(
{
"answer",
"directive_compliance",
"memory_ids",
"mental_model_ids",
"observation_ids",
"model_ids",
}
)
_DONE_ARGUMENT_MARKER_KEYS = _DONE_ARGUMENT_KEYS - {"answer"}
_LEAKED_JSON_ID_KEYS = frozenset({"memory_ids", "mental_model_ids", "observation_ids", "model_ids"})
def _unwrap_leaked_done_arguments(text: str) -> str | None:
"""Return the answer when a done tool call was rendered as JSON text.
Some providers leak the done tool's argument object instead of surfacing it
as a native tool call, e.g. {"answer": "...", "memory_ids": [...]}. Only
unwrap objects that match the done argument shape so normal JSON answers
stay intact.
"""
candidate = text.strip()
if not candidate:
return None
fenced = _JSON_CODE_FENCE_PATTERN.match(candidate)
if fenced:
candidate = fenced.group(1).strip()
try:
payload = json.loads(candidate)
except json.JSONDecodeError:
return None
if not isinstance(payload, dict):
return None
answer = payload.get("answer")
if not isinstance(answer, str) or not answer.strip():
return None
keys = set(payload)
if not keys.intersection(_DONE_ARGUMENT_MARKER_KEYS):
return None
if not keys.issubset(_DONE_ARGUMENT_KEYS):
return None
for key in ("memory_ids", "mental_model_ids", "observation_ids", "model_ids"):
value = payload.get(key)
if value is not None and not isinstance(value, list):
return None
return answer.strip()
def _strip_trailing_id_json_object(text: str) -> str:
stripped = text.rstrip()
if not stripped.endswith("}"):
return text.strip()
start = stripped.rfind("{")
if start < 0:
return text.strip()
try:
payload = json.loads(stripped[start:])
except json.JSONDecodeError:
return text.strip()
if not isinstance(payload, dict) or not payload:
return text.strip()
keys = set(payload)
if not keys.issubset(_LEAKED_JSON_ID_KEYS):
return text.strip()
return stripped[:start].strip()
def _clean_answer_text(text: str) -> str:
@@ -179,10 +103,6 @@ def _clean_answer_text(text: str) -> str:
Some LLMs output the done() call as text instead of a proper tool call.
This strips out patterns like: done({"answer": "...", ...})
"""
unwrapped = _unwrap_leaked_done_arguments(text)
if unwrapped is not None:
return unwrapped
# Remove done() call pattern from the end of the text
cleaned = _DONE_CALL_PATTERN.sub("", text).strip()
return cleaned if cleaned else text
@@ -201,17 +121,13 @@ def _clean_done_answer(text: str) -> str:
if not text:
return text
unwrapped = _unwrap_leaked_done_arguments(text)
if unwrapped is not None:
return unwrapped
cleaned = text
# Remove leaked JSON in code blocks at the end
cleaned = _LEAKED_JSON_SUFFIX.sub("", cleaned).strip()
# Remove leaked raw JSON objects at the end
cleaned = _strip_trailing_id_json_object(cleaned)
cleaned = _LEAKED_JSON_OBJECT.sub("", cleaned).strip()
# Remove trailing ID patterns
cleaned = _TRAILING_IDS_PATTERN.sub("", cleaned).strip()
@@ -224,7 +140,7 @@ async def _generate_structured_output(
response_schema: dict,
llm_config: "LLMProvider",
reflect_id: str,
) -> StructuredOutputResult:
) -> tuple[dict[str, Any] | None, int, int]:
"""Generate structured output from an answer using the provided JSON schema.
Args:
@@ -234,8 +150,8 @@ async def _generate_structured_output(
reflect_id: Reflect ID for logging
Returns:
A StructuredOutputResult carrying the structured output (None if
generation fails) and the call's token usage.
Tuple of (structured_output, input_tokens, output_tokens).
structured_output is None if generation fails.
"""
try:
from typing import Any as TypingAny
@@ -269,7 +185,7 @@ async def _generate_structured_output(
if not fields:
logger.warning(f"[REFLECT {reflect_id}] No fields found in response_schema, skipping structured output")
return StructuredOutputResult()
return None, 0, 0
DynamicModel = create_model("StructuredResponse", **fields)
@@ -322,9 +238,6 @@ OUTPUT:"""
],
response_format=DynamicModel,
scope="reflect_structured",
max_retries=1,
initial_backoff=0.25,
max_backoff=1.0,
skip_validation=True, # We'll handle the dict ourselves
return_usage=True,
)
@@ -345,17 +258,11 @@ OUTPUT:"""
logger.warning(f"[REFLECT {reflect_id}] Required field '{field_name}' is empty in structured output")
logger.info(f"[REFLECT {reflect_id}] Generated structured output with {len(structured_output)} fields")
return StructuredOutputResult(
structured_output=structured_output,
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
cached_tokens=usage.cached_tokens,
thoughts_tokens=usage.thoughts_tokens,
)
return structured_output, usage.input_tokens, usage.output_tokens
except Exception as e:
logger.warning(f"[REFLECT {reflect_id}] Failed to generate structured output: {e}")
return StructuredOutputResult()
return None, 0, 0
def _count_messages_tokens(messages: list[dict[str, Any]]) -> int:
@@ -433,7 +340,6 @@ async def run_reflect_agent(
budget: str | None = None,
max_context_tokens: int = 100_000,
llm_output_language: str | None = None,
cancel_check: Callable[[], None] | None = None,
) -> ReflectAgentResult:
"""
Execute the reflect agent loop using native tool calling.
@@ -470,16 +376,12 @@ async def run_reflect_agent(
# Extract directive rules for tool schema (if any)
directive_rules = _extract_directive_rules(directives) if directives else None
# Get tools for this agent (with directive compliance field if directives exist).
# The expand tool only reads back raw source text (chunks/documents), so it is
# useless and excluded when document text storage is disabled.
include_expand = get_config().store_document_text
# Get tools for this agent (with directive compliance field if directives exist)
tools = get_reflect_tools(
directive_rules=directive_rules,
include_mental_models=has_mental_models,
include_observations=include_observations,
include_recall=include_recall,
include_expand=include_expand,
)
# Build set of enabled tool names to guard against LLM hallucinating disabled tool calls
enabled_tools: frozenset[str] = frozenset(t["function"]["name"] for t in tools if t.get("type") == "function")
@@ -527,14 +429,9 @@ async def run_reflect_agent(
llm_trace: list[dict[str, Any]] = []
context_history: list[dict[str, Any]] = [] # For final prompt fallback
# Token usage tracking - accumulate across all LLM calls.
# cached_tokens and thoughts_tokens are surfaced for cost attribution
# and prompt-cache tuning. Both are subsets of (or parallel to) the
# input/output counts and are NOT double-counted in total_tokens.
# Token usage tracking - accumulate across all LLM calls
total_input_tokens = 0
total_output_tokens = 0
total_cached_tokens = 0
total_thoughts_tokens = 0
# Track available IDs for validation (prevents hallucinated citations)
available_memory_ids: set[str] = set()
@@ -557,8 +454,6 @@ async def run_reflect_agent(
input_tokens=total_input_tokens,
output_tokens=total_output_tokens,
total_tokens=total_input_tokens + total_output_tokens,
cached_tokens=total_cached_tokens,
thoughts_tokens=total_thoughts_tokens,
)
def _log_completion(answer: str, iterations: int, forced: bool = False):
@@ -593,13 +488,6 @@ async def run_reflect_agent(
# under ``auto`` tool choice. None means the full forced path still applies.
stop_forcing_from_iteration: int | None = None
for iteration in range(max_iterations):
# Cooperative cancellation checkpoint: abort the agent loop between
# iterations if the caller (e.g. an HTTP client) has gone away, rather
# than spending another LLM round-trip on a result nobody will read
# (issue #2122). Raises OperationCancelledError when fired.
if cancel_check is not None:
cancel_check()
is_last = iteration == max_iterations - 1
if is_last:
@@ -612,9 +500,7 @@ async def run_reflect_agent(
messages=[
{
"role": "system",
"content": build_final_system_prompt(
bank_profile.get("mission"), llm_output_language, directives
),
"content": build_final_system_prompt(bank_profile.get("mission"), llm_output_language),
},
{"role": "user", "content": prompt},
],
@@ -625,8 +511,6 @@ async def run_reflect_agent(
llm_duration = int((time.time() - llm_start) * 1000)
total_input_tokens += usage.input_tokens
total_output_tokens += usage.output_tokens
total_cached_tokens += getattr(usage, "cached_tokens", 0) or 0
total_thoughts_tokens += getattr(usage, "thoughts_tokens", 0) or 0
llm_trace.append(
{
"scope": "final",
@@ -640,12 +524,11 @@ async def run_reflect_agent(
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
structured_output = struct.structured_output
total_input_tokens += struct.input_tokens
total_output_tokens += struct.output_tokens
total_cached_tokens += struct.cached_tokens
total_thoughts_tokens += struct.thoughts_tokens
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
total_input_tokens += struct_in
total_output_tokens += struct_out
_log_completion(answer, iteration + 1, forced=True)
return ReflectAgentResult(
@@ -677,9 +560,7 @@ async def run_reflect_agent(
messages=[
{
"role": "system",
"content": build_final_system_prompt(
bank_profile.get("mission"), llm_output_language, directives
),
"content": build_final_system_prompt(bank_profile.get("mission"), llm_output_language),
},
{"role": "user", "content": prompt},
],
@@ -690,8 +571,6 @@ async def run_reflect_agent(
llm_duration = int((time.time() - llm_start) * 1000)
total_input_tokens += usage.input_tokens
total_output_tokens += usage.output_tokens
total_cached_tokens += getattr(usage, "cached_tokens", 0) or 0
total_thoughts_tokens += getattr(usage, "thoughts_tokens", 0) or 0
llm_trace.append(
{
"scope": "final",
@@ -704,12 +583,11 @@ async def run_reflect_agent(
structured_output = None
if response_schema and answer:
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
structured_output = struct.structured_output
total_input_tokens += struct.input_tokens
total_output_tokens += struct.output_tokens
total_cached_tokens += struct.cached_tokens
total_thoughts_tokens += struct.thoughts_tokens
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
total_input_tokens += struct_in
total_output_tokens += struct_out
_log_completion(answer, iteration + 1, forced=True)
return ReflectAgentResult(
@@ -766,8 +644,6 @@ async def run_reflect_agent(
consecutive_errors = 0
total_input_tokens += result.input_tokens
total_output_tokens += result.output_tokens
total_cached_tokens += getattr(result, "cached_tokens", 0) or 0
total_thoughts_tokens += getattr(result, "thoughts_tokens", 0) or 0
llm_trace.append(
{
"scope": f"agent_{iteration + 1}",
@@ -803,9 +679,7 @@ async def run_reflect_agent(
messages=[
{
"role": "system",
"content": build_final_system_prompt(
bank_profile.get("mission"), llm_output_language, directives
),
"content": build_final_system_prompt(bank_profile.get("mission"), llm_output_language),
},
{"role": "user", "content": prompt},
],
@@ -816,8 +690,6 @@ async def run_reflect_agent(
llm_duration = int((time.time() - llm_start) * 1000)
total_input_tokens += usage.input_tokens
total_output_tokens += usage.output_tokens
total_cached_tokens += getattr(usage, "cached_tokens", 0) or 0
total_thoughts_tokens += getattr(usage, "thoughts_tokens", 0) or 0
llm_trace.append(
{
"scope": "final",
@@ -831,12 +703,11 @@ async def run_reflect_agent(
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
structured_output = struct.structured_output
total_input_tokens += struct.input_tokens
total_output_tokens += struct.output_tokens
total_cached_tokens += struct.cached_tokens
total_thoughts_tokens += struct.thoughts_tokens
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
total_input_tokens += struct_in
total_output_tokens += struct_out
_log_completion(answer, iteration + 1, forced=True)
return ReflectAgentResult(
@@ -893,8 +764,6 @@ async def run_reflect_agent(
)
total_input_tokens += rewrite_usage.input_tokens
total_output_tokens += rewrite_usage.output_tokens
total_cached_tokens += getattr(rewrite_usage, "cached_tokens", 0) or 0
total_thoughts_tokens += getattr(rewrite_usage, "thoughts_tokens", 0) or 0
llm_trace.append(
{
"scope": "final_rewrite",
@@ -908,12 +777,11 @@ async def run_reflect_agent(
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
structured_output = struct.structured_output
total_input_tokens += struct.input_tokens
total_output_tokens += struct.output_tokens
total_cached_tokens += struct.cached_tokens
total_thoughts_tokens += struct.thoughts_tokens
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
total_input_tokens += struct_in
total_output_tokens += struct_out
_log_completion(answer, iteration + 1)
return ReflectAgentResult(
@@ -935,9 +803,7 @@ async def run_reflect_agent(
messages=[
{
"role": "system",
"content": build_final_system_prompt(
bank_profile.get("mission"), llm_output_language, directives
),
"content": build_final_system_prompt(bank_profile.get("mission"), llm_output_language),
},
{"role": "user", "content": prompt},
],
@@ -948,8 +814,6 @@ async def run_reflect_agent(
llm_duration = int((time.time() - llm_start) * 1000)
total_input_tokens += usage.input_tokens
total_output_tokens += usage.output_tokens
total_cached_tokens += getattr(usage, "cached_tokens", 0) or 0
total_thoughts_tokens += getattr(usage, "thoughts_tokens", 0) or 0
llm_trace.append(
{
"scope": "final",
@@ -963,12 +827,11 @@ async def run_reflect_agent(
# Generate structured output if schema provided
structured_output = None
if response_schema and answer:
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
structured_output = struct.structured_output
total_input_tokens += struct.input_tokens
total_output_tokens += struct.output_tokens
total_cached_tokens += struct.cached_tokens
total_thoughts_tokens += struct.thoughts_tokens
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
total_input_tokens += struct_in
total_output_tokens += struct_out
_log_completion(answer, iteration + 1, forced=True)
return ReflectAgentResult(
@@ -1045,9 +908,7 @@ async def run_reflect_agent(
hallucinated_tools = []
for tc in other_tools:
norm = _normalize_tool_name(tc.name)
# "done" is always available. "expand" is governed by enabled_tools
# (it is excluded when text storage is disabled), so it is not hardcoded here.
if enabled_tools is not None and norm not in enabled_tools and norm != "done":
if enabled_tools is not None and norm not in enabled_tools and norm not in ("done", "expand"):
hallucinated_tools.append(tc)
else:
allowed_tools.append(tc)
@@ -1263,15 +1124,14 @@ async def _process_done_tool(
structured_output = None
final_usage = usage
if response_schema and llm_config and answer:
struct = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
structured_output = struct.structured_output
structured_output, struct_in, struct_out = await _generate_structured_output(
answer, response_schema, llm_config, reflect_id
)
# Add structured output tokens to usage
final_usage = TokenUsageSummary(
input_tokens=usage.input_tokens + struct.input_tokens,
output_tokens=usage.output_tokens + struct.output_tokens,
total_tokens=usage.total_tokens + struct.input_tokens + struct.output_tokens,
cached_tokens=usage.cached_tokens + struct.cached_tokens,
thoughts_tokens=usage.thoughts_tokens + struct.thoughts_tokens,
input_tokens=usage.input_tokens + struct_in,
output_tokens=usage.output_tokens + struct_out,
total_tokens=usage.total_tokens + struct_in + struct_out,
)
log_completion(answer, iterations)
@@ -1376,10 +1236,8 @@ async def _execute_tool(
# Normalize tool name for various LLM output formats
tool_name = _normalize_tool_name(tool_name)
# Guard against LLMs hallucinating calls to tools that were not provided.
# "done" is always available; "expand" is governed by enabled_tools (excluded
# when text storage is disabled), so it is not hardcoded as always-allowed here.
if enabled_tools is not None and tool_name not in enabled_tools and tool_name != "done":
# Guard against LLMs hallucinating calls to tools that were not provided
if enabled_tools is not None and tool_name not in enabled_tools and tool_name not in ("done", "expand"):
return {"error": f"Tool '{tool_name}' is not available. Use only the tools provided to you."}
if tool_name == "search_mental_models":
@@ -26,13 +26,10 @@ or stay the same per refresh, never get worse.
from __future__ import annotations
import json
import logging
from typing import Annotated, Any, Literal, Union
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
from hindsight_api.engine.llm_wrapper import parse_llm_json
from pydantic import BaseModel, ConfigDict, Field
from .structured_doc import (
Block,
@@ -147,27 +144,6 @@ Operation = Annotated[
Field(discriminator="op"),
]
_OPERATION_ADAPTER: TypeAdapter[Operation] = TypeAdapter(Operation)
def _validate_operations_list(raw_ops: Any) -> tuple[list[Operation], list[dict[str, Any]]]:
"""Validate each operation independently; drop invalid ops instead of failing the batch."""
if not isinstance(raw_ops, list):
raise TypeError(f"operations must be a list, got {type(raw_ops)!r}")
valid: list[Operation] = []
skipped: list[dict[str, Any]] = []
for i, item in enumerate(raw_ops):
try:
valid.append(_OPERATION_ADAPTER.validate_python(item))
except ValidationError as exc:
skipped.append({"index": i, "op": item, "error": exc.errors(include_url=False)})
logger.warning(
"[STRUCTURED_DELTA] skipping invalid operation at index %s: %s",
i,
exc.errors(include_url=False),
)
return valid, skipped
class DeltaOperationList(BaseModel):
"""Container for the operations produced by an LLM delta call."""
@@ -176,104 +152,6 @@ class DeltaOperationList(BaseModel):
operations: list[Operation] = Field(default_factory=list)
class DeltaAllOpsInvalidError(ValueError):
"""Raised when the model emitted operations but none survived validation.
Distinct from an empty ``operations`` array (a legitimate no-op): here every
op was malformed, so returning zero valid ops would make the caller apply
nothing and silently drop this refresh's new facts. Raising instead lets the
caller fall back to a full rewrite, which still integrates the new facts.
"""
def _finalize_operations(valid: list[Operation], skipped: list[dict[str, Any]]) -> DeltaOperationList:
"""Build the result, but refuse a wholesale validation failure as a silent no-op."""
if skipped and not valid:
raise DeltaAllOpsInvalidError(f"all {len(skipped)} delta operation(s) failed validation")
return DeltaOperationList(operations=valid)
def _extract_balanced_json_object(text: str) -> str | None:
"""Return the first top-level ``{...}`` slice, ignoring trailing junk."""
start = text.find("{")
if start < 0:
return None
depth = 0
in_string = False
escape = False
for i in range(start, len(text)):
ch = text[i]
if in_string:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return text[start : i + 1]
return None
def parse_delta_operation_list(raw: Any) -> DeltaOperationList:
"""Parse structured-delta LLM output into a validated operation list."""
if isinstance(raw, DeltaOperationList):
return raw
if isinstance(raw, dict):
ops_raw = raw.get("operations", [])
valid, skipped = _validate_operations_list(ops_raw)
if skipped:
logger.info(
"[STRUCTURED_DELTA] parsed %s op(s), skipped %s invalid op(s) from dict payload",
len(valid),
len(skipped),
)
return _finalize_operations(valid, skipped)
text = (raw or "").strip()
if not text:
return DeltaOperationList()
candidates: list[str] = [text]
extracted = _extract_balanced_json_object(text)
if extracted and extracted != text:
candidates.append(extracted)
last_error: Exception | None = None
for candidate in candidates:
try:
payload = parse_llm_json(candidate)
except json.JSONDecodeError as exc:
last_error = exc
continue
if not isinstance(payload, dict) or "operations" not in payload:
last_error = ValueError("delta payload must be an object with an operations array")
continue
try:
valid, skipped = _validate_operations_list(payload["operations"])
except TypeError as exc:
last_error = exc
continue
if skipped:
logger.info(
"[STRUCTURED_DELTA] parsed %s op(s), skipped %s invalid op(s)",
len(valid),
len(skipped),
)
return _finalize_operations(valid, skipped)
if last_error is not None:
raise last_error
return DeltaOperationList()
# Application ---------------------------------------------------------------
@@ -78,32 +78,9 @@ class DirectiveInfo(BaseModel):
class TokenUsageSummary(BaseModel):
"""Total token usage across all LLM calls."""
input_tokens: int = Field(default=0, description="Total input tokens used (includes any cached prefix tokens)")
output_tokens: int = Field(default=0, description="Total visible output tokens used (excludes reasoning/thoughts)")
total_tokens: int = Field(default=0, description="Total tokens (input + output, excludes thoughts)")
cached_tokens: int = Field(
default=0,
description="Cached/cache-read prompt tokens summed across calls. Subset of input_tokens.",
)
thoughts_tokens: int = Field(
default=0,
description=(
"Reasoning/thinking tokens summed across calls. Billed at the output rate by some providers "
"but not part of visible output."
),
)
class StructuredOutputResult(BaseModel):
"""Result of structured-output generation, including token usage for the call."""
structured_output: dict[str, Any] | None = Field(
default=None, description="Generated structured output, or None if generation failed"
)
input_tokens: int = Field(default=0, description="Input tokens used")
output_tokens: int = Field(default=0, description="Visible output tokens used")
cached_tokens: int = Field(default=0, description="Cached prefix tokens. Subset of input_tokens.")
thoughts_tokens: int = Field(default=0, description="Reasoning/thinking tokens, when reported by the provider")
input_tokens: int = Field(default=0, description="Total input tokens used")
output_tokens: int = Field(default=0, description="Total output tokens used")
total_tokens: int = Field(default=0, description="Total tokens (input + output)")
class ReflectAgentResult(BaseModel):
@@ -604,44 +604,16 @@ Just provide the direct answer with proper markdown formatting.
CRITICAL: This is a NON-CONVERSATIONAL system. NEVER ask follow-up questions, offer to search again, suggest alternatives, or end with anything like "Would you like me to..." or "Let me know if...". The user cannot reply. Your answer must be complete and self-contained."""
# The final synthesis is a SEPARATE LLM call with its own system prompt — the
# agent/reasoning system prompt (which carries directives and the language rule)
# is NOT in scope here. So this default language rule, and the directives, must
# be repeated for the answer-writing model. Without it, weaker models drift to
# English even when the question/facts are in another language or a directive
# demands a specific one (the cause of flaky multilingual reflect tests).
_FINAL_LANGUAGE_RULE = (
"## LANGUAGE\n"
"- Respond in the SAME language as the user's question "
"(e.g. a question in Chinese gets a Chinese answer; Japanese → Japanese).\n"
"- If a directive above specifies a response language, follow the directive — "
"it takes precedence over this default."
)
def build_final_system_prompt(
mission: str | None = None,
llm_output_language: str | None = None,
directives: list[dict[str, Any]] | None = None,
) -> str:
def build_final_system_prompt(mission: str | None = None, llm_output_language: str | None = None) -> str:
"""Build the final synthesis system prompt, using mission as role when set.
``directives`` are re-injected here (they live in the agent/reasoning prompt,
but the final answer is a separate call) so output-constraining rules most
visibly response language are honoured by the model that actually writes
the answer. When ``llm_output_language`` is set it forces that language
regardless of the query/source/directive language (config override wins).
When ``llm_output_language`` is set, the response is forced into that
language regardless of the query/source language.
"""
from hindsight_api.engine.prompt_utils import escape_for_prompt, output_language_directive
role_section = escape_for_prompt(mission.strip()) if mission else _DEFAULT_FINAL_ROLE
parts = [build_directives_section(directives) if directives else ""]
parts.append(_FINAL_SYSTEM_PROMPT_BASE.format(role_section=role_section))
parts.append(_FINAL_LANGUAGE_RULE)
parts.append(build_directives_reminder(directives) if directives else "")
return "\n\n".join(p.strip() for p in parts if p.strip()) + output_language_directive(llm_output_language)
return _FINAL_SYSTEM_PROMPT_BASE.format(role_section=role_section) + output_language_directive(llm_output_language)
# Backward-compatible constant for non-identity missions
@@ -734,65 +706,7 @@ Examples
``{"operations": [{"op": "replace_block", "section_id": "overview",
"index": 0, "block": {"type": "paragraph", "text": "Updated summary."}}]}``
- Remove an obsolete block
``{"operations": [{"op": "remove_block", "section_id": "status", "index": 2}]}``
JSON STRING RULES (critical)
- Every ``text`` and ``items`` string must be valid JSON: escape ``"`` as ``\\"``,
backslashes as ``\\\\``, and newlines as ``\\n``. Do not use raw backticks inside
strings unless needed; prefer plain quotes for file paths.
- ``replace_block``, ``insert_block``, and ``remove_block`` MUST include ``index`` (0-based block position in that section). Use ``replace_section_blocks`` only when replacing every block in a section.
- Do not append extra ``]`` or ``}`` after the closing ``}`` of the root object."""
_STRUCTURED_DELTA_DEFAULT_MAX_INPUT_TOKENS = 24_000
def _truncate_cl100k(text: str, max_tokens: int) -> str:
"""Truncate text to at most max_tokens using cl100k_base."""
if max_tokens <= 0:
return ""
from .tokenization import count_cl100k_tokens
if count_cl100k_tokens(text) <= max_tokens:
return text
enc = __import__("tiktoken").get_encoding("cl100k_base")
return enc.decode(enc.encode(text)[:max_tokens])
def _fit_structured_delta_prompt_parts(
*,
source_query: str,
current_document_json: str,
candidate_markdown: str,
facts_block: str,
budget_hint: str,
task_footer: str,
max_input_tokens: int,
) -> tuple[str, str, str, bool]:
"""Shrink large prompt sections to fit within max_input_tokens (cl100k estimate)."""
from .tokenization import count_cl100k_tokens
fixed = (
f"## Topic\n{source_query}\n\n"
f"## CURRENT DOCUMENT (apply ops to this; reference section ids as listed)\n"
f"```json\n\n```\n\n"
f"## NEW INFORMATION SYNTHESIS (context for how new facts relate to the topic)\n"
f"```markdown\n\n```\n\n"
f"## SUPPORTING FACTS (new since last refresh — integrate these)\n"
f"{budget_hint}\n\n"
f"{task_footer}"
)
facts_header = "## SUPPORTING FACTS (new since last refresh — integrate these)\n"
facts_prefix_tokens = count_cl100k_tokens(facts_header)
reserved_facts = min(4096, max(512, max_input_tokens // 8))
doc_budget = max(1024, (max_input_tokens - count_cl100k_tokens(fixed) - reserved_facts) * 55 // 100)
cand_budget = max(512, (max_input_tokens - count_cl100k_tokens(fixed) - reserved_facts) * 30 // 100)
facts_budget = max(256, reserved_facts - facts_prefix_tokens)
doc_json = _truncate_cl100k(current_document_json, doc_budget)
candidate = _truncate_cl100k(candidate_markdown, cand_budget)
facts_body = _truncate_cl100k(facts_block, facts_budget)
truncated = doc_json != current_document_json or candidate != candidate_markdown or facts_body != facts_block
return doc_json, candidate, facts_body, truncated
``{"operations": [{"op": "remove_block", "section_id": "status", "index": 2}]}``"""
def build_structured_delta_prompt(
@@ -802,7 +716,6 @@ def build_structured_delta_prompt(
supporting_facts: list[dict[str, Any]],
source_query: str,
max_output_tokens: int | None = None,
max_input_tokens: int | None = None,
) -> str:
"""Build the user prompt for a structured-delta mental model refresh.
@@ -833,39 +746,19 @@ def build_structured_delta_prompt(
"block-level ops) so the response always parses as valid JSON."
)
task_footer = (
return (
f"## Topic\n{source_query}\n\n"
f"## CURRENT DOCUMENT (apply ops to this; reference section ids as listed)\n"
f"```json\n{current_document_json}\n```\n\n"
f"## NEW INFORMATION SYNTHESIS (context for how new facts relate to the topic)\n"
f"```markdown\n{candidate_markdown}\n```\n\n"
f"## SUPPORTING FACTS (new since last refresh — integrate these)\n{facts_block}"
f"{budget_hint}\n\n"
"## Task\n"
"Output a JSON object matching the operations schema. Integrate the new "
"supporting facts into CURRENT DOCUMENT. Add, update, or remove content "
"as needed. Preserve unchanged sections and blocks by not mentioning them."
)
input_cap = max_input_tokens if max_input_tokens is not None else _STRUCTURED_DELTA_DEFAULT_MAX_INPUT_TOKENS
doc_json, candidate, facts_body, input_truncated = _fit_structured_delta_prompt_parts(
source_query=source_query,
current_document_json=current_document_json,
candidate_markdown=candidate_markdown,
facts_block=facts_block,
budget_hint=budget_hint,
task_footer=task_footer,
max_input_tokens=input_cap,
)
truncation_note = ""
if input_truncated:
truncation_note = (
"\n\n*Note: Document, synthesis, or facts were truncated to fit the model "
"context window. Prefer minimal, high-leverage operations.*"
)
return (
f"## Topic\n{source_query}\n\n"
f"## CURRENT DOCUMENT (apply ops to this; reference section ids as listed)\n"
f"```json\n{doc_json}\n```\n\n"
f"## NEW INFORMATION SYNTHESIS (context for how new facts relate to the topic)\n"
f"```markdown\n{candidate}\n```\n\n"
f"## SUPPORTING FACTS (new since last refresh — integrate these)\n{facts_body}"
f"{budget_hint}{truncation_note}\n\n"
f"{task_footer}"
)
DELTA_SYSTEM_PROMPT = """You are performing a surgical delta update to an existing mental model document.
@@ -232,7 +232,6 @@ def get_reflect_tools(
include_mental_models: bool = True,
include_observations: bool = True,
include_recall: bool = True,
include_expand: bool = True,
) -> list[dict]:
"""
Get the list of tools for the reflect agent.
@@ -248,9 +247,6 @@ def get_reflect_tools(
include_mental_models: Whether to include the search_mental_models tool.
include_observations: Whether to include the search_observations tool.
include_recall: Whether to include the recall tool.
include_expand: Whether to include the expand tool. Disabled when raw
document/chunk text is not stored, since expand only reads back
source text and would return empty results.
Returns:
List of tool definitions in OpenAI format
@@ -264,8 +260,7 @@ def get_reflect_tools(
if include_recall:
tools.append(TOOL_RECALL)
if include_expand:
tools.append(TOOL_EXPAND)
tools.append(TOOL_EXPAND)
# Use directive-aware done tool if directives are present
if directive_rules:
@@ -31,20 +31,8 @@ class LLMToolCallResult(BaseModel):
content: str | None = Field(default=None, description="Text content if any")
tool_calls: list[LLMToolCall] = Field(default_factory=list, description="Tool calls requested by the LLM")
finish_reason: str | None = Field(default=None, description="Reason the LLM stopped: 'stop', 'tool_calls', etc.")
input_tokens: int = Field(
default=0,
description="Input tokens used in this call (includes any cached prefix tokens reported by the provider)",
)
output_tokens: int = Field(
default=0, description="Visible output tokens used in this call (excludes reasoning/thoughts)"
)
cached_tokens: int = Field(
default=0, description="Cached prefix tokens, when reported by the provider. Subset of input_tokens."
)
thoughts_tokens: int = Field(
default=0,
description="Reasoning/thinking tokens. Billed at the output rate by some providers but not part of visible output.",
)
input_tokens: int = Field(default=0, description="Input tokens used in this call")
output_tokens: int = Field(default=0, description="Output tokens used in this call")
class ToolCallTrace(BaseModel):
@@ -103,18 +91,9 @@ class TokenUsage(BaseModel):
)
input_tokens: int = Field(default=0, description="Number of input/prompt tokens consumed")
output_tokens: int = Field(
default=0, description="Number of visible output/completion tokens generated (excludes reasoning/thoughts)"
)
total_tokens: int = Field(default=0, description="Total tokens (input + output, excludes thoughts)")
output_tokens: int = Field(default=0, description="Number of output/completion tokens generated")
total_tokens: int = Field(default=0, description="Total tokens (input + output)")
cached_tokens: int = Field(default=0, description="Cached/cache-read prompt tokens, when reported by the provider")
thoughts_tokens: int = Field(
default=0,
description=(
"Reasoning/thinking tokens generated by the model. Billed at the output rate by some providers "
"(e.g. Gemini 2.5+ family) but not surfaced in the visible response."
),
)
def __add__(self, other: "TokenUsage") -> "TokenUsage":
"""Allow aggregating token usage from multiple calls."""
@@ -123,38 +102,9 @@ class TokenUsage(BaseModel):
output_tokens=self.output_tokens + other.output_tokens,
total_tokens=self.total_tokens + other.total_tokens,
cached_tokens=self.cached_tokens + other.cached_tokens,
thoughts_tokens=self.thoughts_tokens + other.thoughts_tokens,
)
class ExtractedFact(BaseModel):
"""A single candidate fact produced by dry-run extraction (no resolution/links/persistence).
A deliberate subset of the persisted memory-unit shape only the fields a fresh extraction
yields. Storage/consolidation/curation fields (id, document_id, chunk_id, proof_count, state, )
are omitted because nothing is stored. Entities are raw, unresolved names.
"""
text: str = Field(description="The extracted fact text.")
fact_type: str = Field(description="Perspective classification: 'world' or 'experience'.")
occurred_start: str | None = Field(default=None, description="ISO timestamp the fact's event started, if dated.")
occurred_end: str | None = Field(default=None, description="ISO timestamp the fact's event ended, if dated.")
entities: list[str] = Field(
default_factory=list, description="Raw (unresolved) entity names mentioned in the fact."
)
class DryRunExtractionResult(BaseModel):
"""Result of dry-run fact extraction: candidate facts plus aggregated LLM token usage."""
facts: list[ExtractedFact] = Field(
default_factory=list, description="Candidate facts the retain step would extract."
)
usage: TokenUsage = Field(
default_factory=TokenUsage, description="Aggregated token usage across the extraction LLM calls."
)
class DispositionTraits(BaseModel):
"""
Disposition traits for a memory bank.
@@ -172,47 +122,6 @@ class DispositionTraits(BaseModel):
model_config = ConfigDict(json_schema_extra={"example": {"skepticism": 3, "literalism": 3, "empathy": 3}})
class RecallScores(BaseModel):
"""Per-result recall scores from different stages of the pipeline.
``final`` is the value results are ranked by. The others are diagnostic and
can be filtered on via the recall ``min_scores`` request parameter. ``semantic``
and ``keyword`` are the raw per-strategy retrieval scores (``None`` when that
strategy did not surface this result); ``reranker`` is the cross-encoder's
normalized relevance.
"""
final: float = Field(description="Final ranking score (combined reranker + recency/temporal/proof boosts)")
reranker: float | None = Field(
default=None,
description="Cross-encoder relevance, normalized 0-1. None when the reranker is a passthrough (rrf/interleave modes).",
)
semantic: float | None = Field(
default=None, description="Vector cosine similarity (0-1). None if this result was not surfaced semantically."
)
keyword: float | None = Field(
default=None,
description="Keyword/full-text (BM25) score (>= 0, unbounded). None if this result was not surfaced by keyword search.",
)
class MinScores(BaseModel):
"""Optional per-stage score floors for recall (all inclusive, AND-ed).
``semantic`` and ``keyword`` are **retrieval-level** cutoffs pushed into the SQL
arms (overriding the global ``semantic_min_similarity`` / ``bm25_min_score``
config for this request), so they prune weak matches before fusion. ``reranker``
and ``final`` are **post-query** filters applied to the scored results after
reranking. Any field left None imposes no floor; all-None (the default) means
no score filtering.
"""
semantic: float | None = Field(default=None, description="Retrieval-level: minimum vector similarity (0-1).")
keyword: float | None = Field(default=None, description="Retrieval-level: minimum keyword/full-text (BM25) score.")
reranker: float | None = Field(default=None, description="Post-query: minimum normalized reranker score (0-1).")
final: float | None = Field(default=None, description="Post-query: minimum final ranking score.")
class MemoryFact(BaseModel):
"""
A single memory fact returned by search or think operations.
@@ -243,7 +152,7 @@ class MemoryFact(BaseModel):
id: str = Field(description="Unique identifier for the memory fact")
text: str = Field(description="The actual text content of the memory")
fact_type: str = Field(description="Type of fact: 'world', 'experience', or 'observation'")
fact_type: str = Field(description="Type of fact: 'world', 'experience', 'opinion', or 'observation'")
entities: list[str] | None = Field(None, description="Entity names mentioned in this fact")
context: str | None = Field(None, description="Additional context for the memory")
occurred_start: str | None = Field(None, description="ISO format date when the event started occurring")
@@ -272,10 +181,6 @@ class MemoryFact(BaseModel):
None,
description="IDs of source facts this observation was derived from (observation type only, when source_facts is enabled)",
)
scores: RecallScores | None = Field(
None,
description="Recall scores from each pipeline stage (final/reranker/semantic/keyword). Not returned for source facts.",
)
class ChunkInfo(BaseModel):
@@ -374,8 +279,7 @@ class ReflectResult(BaseModel):
],
"experience": [],
"opinion": [],
"observation": [],
"mental-models": [],
"mental_models": [],
"directives": [
{
"id": "directive-123",
@@ -392,7 +296,7 @@ class ReflectResult(BaseModel):
text: str = Field(description="The formulated answer text")
based_on: dict[str, Any] = Field(
description="Facts used to formulate the answer, organized by type (world, experience, observation, mental-models, directives)"
description="Facts used to formulate the answer, organized by type (world, experience, mental_models, directives)"
)
structured_output: dict[str, Any] | None = Field(
default=None,
@@ -4,6 +4,7 @@ bank profile utilities for disposition and mission management.
import json
import logging
import re
import uuid
from dataclasses import dataclass
from typing import TypedDict
@@ -8,7 +8,6 @@ import hashlib
import logging
from dataclasses import dataclass
from ...config import get_config
from ..memory_engine import fq_table
from .types import ChunkMetadata
@@ -89,11 +88,6 @@ async def store_chunks_batch(
if not chunks:
return {}
# When document text storage is disabled, persist empty chunk_text (the
# column is NOT NULL) while still computing content_hash from the real text
# so delta-retain dedup is unaffected.
store_text = get_config().store_document_text
# Prepare chunk data for batch insert
chunk_ids = []
chunk_texts = []
@@ -104,7 +98,7 @@ async def store_chunks_batch(
for chunk in chunks:
chunk_id = f"{bank_id}_{document_id}_{chunk.chunk_index}"
chunk_ids.append(chunk_id)
chunk_texts.append(chunk.chunk_text if store_text else "")
chunk_texts.append(chunk.chunk_text)
chunk_indices.append(chunk.chunk_index)
content_hashes.append(compute_chunk_hash(chunk.chunk_text))
chunk_id_map[chunk.chunk_index] = chunk_id
@@ -3,7 +3,6 @@ Embedding generation utilities for memory units.
"""
import asyncio
import contextvars
import logging
from typing import Literal, Protocol
@@ -90,14 +89,7 @@ async def generate_embeddings_batch(
"""
try:
loop = asyncio.get_event_loop()
# run_in_executor runs the encode in a worker thread, which does NOT inherit
# the caller's contextvars. Capture the current context and run the encode
# inside it so context-dependent behavior (e.g. per-bank `user` attribution
# read via get_current_bank_id()) survives the thread hop.
ctx = contextvars.copy_context()
embeddings = await loop.run_in_executor(
None, lambda: ctx.run(_encode_with_input_type, embeddings_backend, texts, input_type)
)
embeddings = await loop.run_in_executor(None, _encode_with_input_type, embeddings_backend, texts, input_type)
except Exception as e:
raise Exception(f"Failed to generate batch embeddings: {str(e)}")
@@ -14,7 +14,7 @@ from typing import Any, Literal, cast
from pydantic import BaseModel, ConfigDict, Field, create_model, field_validator
from ..llm_interface import ProviderRateLimitResetError
from ...config import get_config
from ..llm_wrapper import LLMConfig, OutputTooLongError, sanitize_llm_output
from ..operation_metadata import RetainExtractionErrors
from ..response_models import TokenUsage
@@ -193,7 +193,7 @@ class ExtractedFact(BaseModel):
occurred_start: str | None = Field(default=None, description="ISO timestamp for events")
occurred_end: str | None = Field(default=None, description="ISO timestamp for event end")
fact_type: Literal["world", "assistant"] = Field(
description="'world' = objective/external facts, including user preferences, rules, corrections, and constraints even when stated during a conversation. 'assistant' = actions, experiences, or observations the assistant/agent actually performed."
description="'world' = objective/external facts. 'assistant' = first-person actions, experiences, or observations by the speaker."
)
entities: list[Entity] | None = Field(default=None, description="People, places, concepts")
causal_relations: list[FactCausalRelation] | None = Field(
@@ -296,7 +296,7 @@ class ExtractedFactVerbose(BaseModel):
)
fact_type: Literal["world", "assistant"] = Field(
description="'world' = objective/external facts about the user, other people, events, general knowledge, preferences, rules, corrections, or constraints. 'assistant' = actions, experiences, or observations the assistant/agent actually performed (e.g., 'I changed X', 'I discovered Y')."
description="'world' = objective/external facts about other people, events, general knowledge. 'assistant' = first-person actions, experiences, or observations by the speaker (e.g., 'I changed X', 'I discovered Y')."
)
entities: list[Entity] | None = Field(
@@ -346,7 +346,7 @@ class ExtractedFactNoCausal(BaseModel):
occurred_start: str | None = Field(default=None, description="WHEN the event happened (ISO timestamp).")
occurred_end: str | None = Field(default=None, description="WHEN the event ended (ISO timestamp).")
fact_type: Literal["world", "assistant"] = Field(
description="'world' = about the user/others, including user preferences, rules, corrections, and constraints. 'assistant' = actions or experiences the assistant/agent actually performed."
description="'world' = about the user/others. 'assistant' = experience with assistant."
)
entities: list[Entity] | None = Field(
default=None,
@@ -406,110 +406,64 @@ class VerbatimFactExtractionResponse(BaseModel):
facts: list[VerbatimExtractedFact] = Field(description="List of metadata entries (one per chunk)")
# Separators for sentence-aware recursive text splitting, ordered most- to
# least-preferred. The final "" lets the splitter break mid-word as a last
# resort so a chunk can never exceed the size budget.
_RECURSIVE_TEXT_SEPARATORS = [
"\n\n", # Paragraph breaks
"\n", # Line breaks
". ", # Sentence endings
"! ", # Exclamations
"? ", # Questions
"; ", # Semicolons
", ", # Commas
" ", # Words
"", # Characters (last resort)
]
def chunk_text(text: str, max_chars: int) -> list[str]:
"""
Split text into chunks, preserving conversation structure when possible.
For JSON conversation arrays (user/assistant turns), splits at turn boundaries
while preserving speaker context. For plain text, uses sentence-aware splitting.
def _split_oversized_unit(text: str, max_chars: int) -> list[str]:
"""Sentence-aware split of a single unit that overflowed the budget.
Args:
text: Input text to chunk (plain text or JSON conversation)
max_chars: Maximum characters per chunk (default 120k 30k tokens)
Used when one JSONL line / conversation turn is so large it can't be kept
whole within the configured structured-chunk limit. The resulting fragments
are no longer valid JSON, but the fact extractor treats every chunk as plain
text.
Returns:
List of text chunks, roughly under max_chars
"""
from langchain_text_splitters import RecursiveCharacterTextSplitter
# If text is small enough, return as-is
if len(text) <= max_chars:
return [text]
# Try to parse as JSON conversation array
try:
parsed = json.loads(text)
if isinstance(parsed, list) and all(isinstance(turn, dict) for turn in parsed):
# This looks like a conversation - chunk at turn boundaries
return _chunk_conversation(parsed, max_chars)
except (json.JSONDecodeError, ValueError):
pass
# Fall back to sentence-aware text splitting
splitter = RecursiveCharacterTextSplitter(
chunk_size=max_chars,
chunk_overlap=0,
length_function=len,
is_separator_regex=False,
separators=_RECURSIVE_TEXT_SEPARATORS,
separators=[
"\n\n", # Paragraph breaks
"\n", # Line breaks
". ", # Sentence endings
"! ", # Exclamations
"? ", # Questions
"; ", # Semicolons
", ", # Commas
" ", # Words
"", # Characters (last resort)
],
)
return splitter.split_text(text)
def chunk_text(text: str, max_chars: int, structured_chunk_size: int | None = None) -> list[str]:
"""
Split text into chunks, preserving conversation structure when possible.
For JSON conversation arrays (user/assistant turns) and JSONL (newline-delimited
JSON objects), splits at turn/line boundaries so no object is split across chunks.
A single turn/line that overflows ``max_chars`` is kept whole only up to
``structured_chunk_size``. When unset, that limit defaults to ``max_chars``.
For plain text, uses sentence-aware splitting.
The result is idempotent: re-chunking any chunk this returns yields that chunk
unchanged. The streaming retain pipeline pre-chunks each document once and then
re-chunks every piece during extraction; if a piece re-split, its sub-chunks
would inherit one chunk_index and collide on ``chunk_id`` (issue #2301).
Args:
text: Input text to chunk (plain text, JSON conversation, or JSONL)
max_chars: Target maximum characters per chunk
structured_chunk_size: Maximum characters for a single JSONL line or
conversation turn to keep whole. Defaults to ``max_chars``.
Returns:
List of text chunks, roughly under max_chars
"""
# If text is small enough, return as-is
if len(text) <= max_chars:
return [text]
structured_limit = structured_chunk_size if structured_chunk_size is not None else max_chars
# Try to parse as JSON conversation array
try:
parsed = json.loads(text)
except (json.JSONDecodeError, ValueError):
parsed = None
if isinstance(parsed, list) and all(isinstance(turn, dict) for turn in parsed):
# This looks like a conversation - chunk at turn boundaries
return _chunk_conversation(parsed, max_chars, structured_limit)
if isinstance(parsed, dict):
# A single JSON object — e.g. one JSONL line handed back to the extractor
# after the producer already pre-chunked it. It is one structured unit:
# keep it whole up to the structured limit, else split it as text within
# the chunk budget. Without this, a lone object (one line, so _chunk_jsonl
# declines) would fall through to plain-text splitting and re-split a chunk
# the producer deliberately kept whole — breaking idempotency (issue #2301).
if len(text) <= structured_limit:
return [text]
return _split_oversized_unit(text, max_chars)
# Try to parse as JSONL (newline-delimited JSON objects, e.g. session logs)
jsonl_chunks = _chunk_jsonl(text, max_chars, structured_limit)
if jsonl_chunks is not None:
return jsonl_chunks
# Fall back to sentence-aware text splitting
return _split_oversized_unit(text, max_chars)
def _chunk_conversation(turns: list[dict], max_chars: int, structured_limit: int) -> list[str]:
def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
"""
Chunk a conversation array at turn boundaries, preserving complete turns.
Args:
turns: List of conversation turn dicts (with 'role' and 'content' keys)
max_chars: Maximum characters per chunk
structured_limit: Maximum characters for a single turn to keep whole
Returns:
List of JSON-serialized chunks, each containing complete turns
@@ -519,109 +473,28 @@ def _chunk_conversation(turns: list[dict], max_chars: int, structured_limit: int
current_chunk = []
current_size = 2 # Account for "[]"
def _flush() -> None:
nonlocal current_chunk, current_size
if current_chunk:
chunks.append(json.dumps(current_chunk, ensure_ascii=False))
current_chunk = []
current_size = 2 # Reset to "[]"
for turn in turns:
# Estimate size of this turn when serialized (with comma separator)
turn_json = json.dumps(turn, ensure_ascii=False)
turn_unit_size = len(turn_json)
turn_size = turn_unit_size + 1 # +1 for comma
# A turn too large to keep whole even alone: flush, then split it as
# text. Fragment within min(structured_limit, max_chars) so no fragment
# exceeds the chunk budget — otherwise a downstream re-chunk would split
# it again and collide on chunk_id (issue #2301).
if turn_unit_size > structured_limit:
_flush()
chunks.extend(_split_oversized_unit(turn_json, min(structured_limit, max_chars)))
continue
turn_size = len(turn_json) + 1 # +1 for comma
# If adding this turn would exceed limit and we have turns, save current chunk
if current_size + turn_size > max_chars and current_chunk:
_flush()
chunks.append(json.dumps(current_chunk, ensure_ascii=False))
current_chunk = []
current_size = 2 # Reset to "[]"
# Add turn to current chunk
current_chunk.append(turn)
current_size += turn_size
# Add final chunk if non-empty
_flush()
if current_chunk:
chunks.append(json.dumps(current_chunk, ensure_ascii=False))
return chunks if chunks else [json.dumps(turns, ensure_ascii=False)]
def _chunk_jsonl(text: str, max_chars: int, structured_limit: int) -> list[str] | None:
"""Chunk newline-delimited JSON (JSONL) at line boundaries.
Detects JSONL two or more non-empty lines, each a complete JSON object
and packs whole lines into chunks so no line is split across chunks (multiple
short lines may share a chunk). A line that overflows ``max_chars`` is kept
whole only up to ``structured_limit``. Returns ``None`` if the input is not
JSONL, so the caller falls back to plain-text splitting.
Args:
text: Input text to inspect/chunk.
max_chars: Maximum characters per chunk.
structured_limit: Maximum characters for a single JSONL line to
keep whole.
Returns:
List of JSONL chunks (lines joined by newline), or ``None`` if not JSONL.
"""
lines = [line for line in text.splitlines() if line.strip()]
if len(lines) < 2:
return None
for line in lines:
try:
obj = json.loads(line)
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(obj, dict):
return None
chunks: list[str] = []
current_chunk: list[str] = []
current_size = 0
def _flush() -> None:
nonlocal current_chunk, current_size
if current_chunk:
chunks.append("\n".join(current_chunk))
current_chunk = []
current_size = 0
for line in lines:
line_unit_size = len(line)
line_size = len(line) + 1 # +1 for the joining newline
# A line too large to keep whole even alone: flush, then split it as
# text. Fragment within min(structured_limit, max_chars) so no fragment
# exceeds the chunk budget — otherwise a downstream re-chunk would split
# it again and collide on chunk_id (issue #2301).
if line_unit_size > structured_limit:
_flush()
chunks.extend(_split_oversized_unit(line, min(structured_limit, max_chars)))
continue
# If adding this line would exceed the limit and we have lines, flush.
# A line up to structured_limit is kept whole (a bounded overflow).
if current_size + line_size > max_chars and current_chunk:
_flush()
current_chunk.append(line)
current_size += line_size
_flush()
return chunks
# =============================================================================
# FACT EXTRACTION PROMPTS
# =============================================================================
@@ -663,8 +536,8 @@ fact_kind:
- "conversation": Ongoing state, preference, trait (no dates)
fact_type:
- "world": Objective/external facts, including the user's preferences, rules, corrections, constraints, plans, traits, or context. These stay "world" even when the user states them during an assistant interaction (e.g., "User prefers browser_navigate over web_search", "User corrected the project deadline").
- "assistant": Actions, experiences, or observations the assistant/agent actually performed (e.g., "I changed X", "I discovered Y", "I debugged Z"). Use this for the assistant/agent doing, trying, learning, deciding, recommending, or responding not merely for user facts mentioned in conversation.
- "world": About other people, external events, general knowledge, objective facts
- "assistant": First-person actions, experiences, or observations by the speaker/author (e.g., "I changed X", "I discovered Y", "I debugged Z"). Also includes interactions with the user (requests, recommendations). If the narrator describes something they did, tried, learned, or decided use "assistant".
TEMPORAL HANDLING
@@ -766,7 +639,7 @@ RULES:
- Extract all entities (people, places, organizations, objects, concepts).
- Extract temporal information (occurred_start, occurred_end, fact_kind, when).
- Extract location (where) and people (who).
- fact_type: use "world" for user preferences, rules, corrections, constraints, traits, and other objective facts, even when stated during an assistant interaction. Use "assistant" only for actions or experiences the assistant/agent actually performed."""
- fact_type: use "world" unless the content is clearly an interaction with the assistant."""
VERBATIM_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
retain_mission_section="{retain_mission_section}",
@@ -867,8 +740,8 @@ For CONVERSATIONS (fact_kind="conversation"):
FACT TYPE
- **world**: User's life, preferences, rules, corrections, constraints, other people, and events (facts that would exist without this conversation)
- **assistant**: Actions or experiences the assistant/agent actually performed while helping the user (requests, recommendations, help)
- **world**: User's life, other people, events (would exist without this conversation)
- **assistant**: Interactions with assistant (requests, recommendations, help)
CRITICAL for assistant facts: ALWAYS capture the user's request/question in the fact!
Include: what the user asked, what problem they wanted solved, what context they provided
@@ -1203,17 +1076,9 @@ def _build_request_body(llm_config, config, prompt: str, user_message: str, resp
request_body = {
"model": llm_config.model,
"messages": [{"role": "system", "content": prompt}, {"role": "user", "content": user_message}],
"temperature": 0.1,
}
# Honour the configured retain temperature. ``None`` omits the parameter
# entirely (for models like Azure GPT-5.5 that reject explicit temperatures),
# mirroring LLMProvider.call, which drops temperature when it is None. The
# batch path builds the request body directly instead of going through
# LLMProvider.call (#2469 only de-hardcoded the streaming path), so it must
# apply the same rule here.
if config.llm_temperature_retain is not None:
request_body["temperature"] = config.llm_temperature_retain
# Add max_completion_tokens if configured
if config.retain_max_completion_tokens:
request_body["max_completion_tokens"] = config.retain_max_completion_tokens
@@ -1322,7 +1187,7 @@ async def _extract_facts_from_chunk(
messages=[{"role": "system", "content": prompt}, {"role": "user", "content": user_message}],
response_format=response_schema,
scope="retain_extract_facts",
temperature=config.llm_temperature_retain,
temperature=0.1,
max_completion_tokens=config.retain_max_completion_tokens,
max_retries=llm_max_retries,
initial_backoff=initial_backoff,
@@ -1769,11 +1634,7 @@ async def extract_facts_from_text(
- chunks: List of tuples (chunk_text, fact_count) for each chunk
- usage: Aggregated token usage across all LLM calls
"""
chunks = chunk_text(
text,
max_chars=config.retain_chunk_size,
structured_chunk_size=config.retain_structured_chunk_size,
)
chunks = chunk_text(text, max_chars=config.retain_chunk_size)
# Log chunk count before starting LLM requests
total_chars = sum(len(c) for c in chunks)
@@ -1822,28 +1683,10 @@ async def extract_facts_from_text(
total_usage = total_usage + chunk_usage
if failed_chunks:
# Include the exception message — not just the type — so operators
# can tell a structured-JSON parse failure apart from a rate limit
# apart from a network 5xx, all of which can surface as the same
# exception types. The error_message we propagate to the
# async_operations row is the only inspection surface a worker-side
# failure leaves behind, and a bare "chunk 0: RuntimeError" is not
# actionable.
failed_summary = ", ".join(f"chunk {idx}: {type(err).__name__}: {err}" for idx, err in failed_chunks[:5])
quota_errors = [err for _, err in failed_chunks if isinstance(err, ProviderRateLimitResetError)]
if quota_errors and len(quota_errors) == len(failed_chunks):
retry_at = max(err.retry_at for err in quota_errors)
raise ProviderRateLimitResetError(
retry_at=retry_at,
message=(
f"Fact extraction deferred by provider quota: {len(failed_chunks)}/{len(chunks)} chunks failed. "
f"First failures: {failed_summary}. Provider detail: {quota_errors[0]}"
),
) from quota_errors[0]
# Fail the entire retain — partial extraction is not acceptable.
# All successfully extracted facts are discarded because the transaction
# hasn't committed yet. The worker poller will retry the entire task.
failed_summary = ", ".join(f"chunk {idx}: {type(err).__name__}" for idx, err in failed_chunks[:5])
raise RuntimeError(
f"Fact extraction failed: {len(failed_chunks)}/{len(chunks)} chunks failed. "
f"First failures: {failed_summary}"
@@ -1933,7 +1776,8 @@ async def extract_facts_from_contents_batch_api(
logger.info(f"Using Batch API for fact extraction ({len(contents)} contents)")
# Check config for causal link extraction (used throughout)
# Check config for extraction mode and causal link extraction (used throughout)
extraction_mode = config.retain_extraction_mode
extract_causal_links = config.retain_extract_causal_links
# Check if provider supports batch API
@@ -1974,11 +1818,7 @@ async def extract_facts_from_contents_batch_api(
prompt, response_schema = _build_extraction_prompt_and_schema(config)
for content_index, item in enumerate(contents):
chunks = chunk_text(
item.content,
max_chars=config.retain_chunk_size,
structured_chunk_size=config.retain_structured_chunk_size,
)
chunks = chunk_text(item.content, max_chars=config.retain_chunk_size)
for chunk_index_in_content, chunk in enumerate(chunks):
all_chunks_info.append((chunk, content_index, chunk_index_in_content, item.event_date, item.context))
@@ -2399,11 +2239,7 @@ def _extract_facts_chunks(
global_chunk_idx = 0
for content_index, content in enumerate(contents):
chunks = chunk_text(
content.content,
config.retain_chunk_size,
structured_chunk_size=config.retain_structured_chunk_size,
)
chunks = chunk_text(content.content, config.retain_chunk_size)
for chunk in chunks:
chunks_metadata.append(
ChunkMetadata(
@@ -399,12 +399,7 @@ async def _upsert_document_row(
INSERT so that re-ingesting a document (which deletes + inserts the row)
keeps the original creation timestamp. ``updated_at`` is always set to
``NOW()`` on both INSERT and the ON CONFLICT UPDATE branch.
When ``store_document_text`` is disabled, the raw source text
is dropped and ``original_text`` is stored as NULL. The ``content_hash`` is
still computed from the real content so delta-retain dedup is unaffected.
"""
original_text = combined_content if get_config().store_document_text else None
await conn.execute(
f"""
INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, retain_params, tags, created_at, updated_at)
@@ -418,7 +413,7 @@ async def _upsert_document_row(
""",
document_id,
bank_id,
original_text,
combined_content,
content_hash,
json.dumps(retain_params) if retain_params else None,
document_tags or [],
@@ -574,10 +574,12 @@ async def compute_semantic_links_ann(
# the transaction end handles both.
rows: list = []
async with conn.transaction():
# Transaction-local ANN tuning. The dispatcher only returns GUCs that
# are safe to apply at session/transaction scope for the configured
# backend. VectorChord probe values are index-shaped, so vchordrq uses
# index storage fallback parameters instead of a blanket SET LOCAL.
# Transaction-local ANN tuning. Each supported backend exposes its own
# GUC (hnsw.ef_search on pgvector, vchordrq.probes on vchord); the
# dispatcher returns the right knob for the configured backend with a
# value tuned for top-50 semantic link creation (lower recall but much
# lower latency than the recall-side default). SET LOCAL auto-reverts
# at commit, so we don't pollute the pool for subsequent queries.
for guc, value in ann_search_tuning_settings(configured_vector_extension(), kind="low_latency"):
await conn.execute(f"SET LOCAL {guc} = {value}")
@@ -634,7 +636,7 @@ async def compute_semantic_links_ann(
logger.debug(f"[ANN] fact_type={fact_type}: {len(ft_rows)} rows in {time_mod.time() - t_query:.3f}s")
rows.extend(ft_rows)
# Transaction commits here. _ann_seeds is dropped (ON COMMIT DROP).
# Transaction-local ANN tuning reverts (SET LOCAL).
# hnsw.ef_search reverts (SET LOCAL).
for row in rows:
sim = float(min(1.0, max(0.0, row["similarity"])))
@@ -800,6 +802,8 @@ async def create_causal_links_batch(
try:
import time as time_mod
create_start = time_mod.time()
# Build links list
links = []
for fact_idx, causal_relations in enumerate(causal_relations_per_fact):
@@ -15,166 +15,18 @@ from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from ...extensions.memory_defense import (
DefenseAction,
DefenseDecision,
MemoryDefenseExtension,
apply_redaction,
parse_policy,
)
from ...worker.stage import set_stage
from ..db.base import DatabaseBackend
from ..db_utils import acquire_with_retry
from ..memory_engine import count_tokens, fq_table
from . import bank_utils
@dataclass
class BlockedViolation:
"""One item blocked by the Memory Defense policy (surfaced in the 422 body)."""
index: int
detector: str | None
message: str
class MemoryDefenseAllBlockedError(Exception):
"""Raised when every item in a retain batch is blocked by the Memory Defense policy."""
def __init__(self, violations: list[BlockedViolation]) -> None:
self.violations = violations
super().__init__(f"all {len(violations)} items blocked by Memory Defense policy")
def utcnow():
"""Get current UTC time."""
return datetime.now(UTC)
def _redact_document_body(body: str, config: Any) -> str:
"""Apply Memory Defense redaction to a document body.
Per-item screening only scrubs the chunked content that goes through
`screen()`. When a sub-batch carries `document_body_override` (the full
original text of an oversized item see `_split_contents_into_sub_batches`),
that override bypasses screening and would persist verbatim into
`documents.original_text`. Apply the same redactor here so the document
body is scrubbed regardless of which path produced it.
"""
try:
policy = parse_policy(getattr(config, "memory_defense", None))
except Exception:
return body
if not policy.enabled:
return body
if not any(r.on == "sensitive_data" for r in policy.rules):
return body
return apply_redaction(body).content
async def _fire_memory_defense_webhook(
webhook_manager: Any,
*,
conn: Any,
schema: str | None,
bank_id: str,
operation_id: str | None,
document_id: str | None,
decision: DefenseDecision,
) -> None:
"""Fire a memory_defense.triggered webhook for a non-allow decision.
No-op when no webhook manager is wired or none is subscribed. Delivery
failures are swallowed so screening never blocks a retain.
"""
if webhook_manager is None:
return
try:
from ...webhooks import (
MemoryDefenseEventData,
MemoryDefenseHit,
WebhookEvent,
WebhookEventType,
)
# Translate per-match raw dicts on the decision into MemoryDefenseHit
# entries on the wire. The decision's hits list is already fingerprinted
# by apply_redaction (the raw value never lands in hits, by contract),
# so this is purely a shape conversion. None when no per-hit data is
# available so receivers can distinguish "no preview info" from
# "scanned, nothing matched" (the latter wouldn't be a webhook delivery
# in the first place).
decision_hits = getattr(decision, "hits", None) or []
hits: list[MemoryDefenseHit] | None = [
MemoryDefenseHit(
detector=str(h.get("detector") or ""),
preview=str(h.get("preview") or ""),
)
for h in decision_hits
if h.get("detector") and h.get("preview")
] or None
event = WebhookEvent(
event=WebhookEventType.MEMORY_DEFENSE_TRIGGERED,
bank_id=bank_id,
operation_id=operation_id or "",
status=decision.action.value,
timestamp=utcnow(),
data=MemoryDefenseEventData(
action=decision.action.value,
detector=decision.detector,
document_id=document_id,
matched_types=decision.matched_types or None,
message=decision.message or None,
hits=hits,
# Optional SIEM-enrichment fields populated by downstream
# extensions (e.g. hindsight-cloud's _CloudDefenseDecision
# subclass). Read via getattr so OSS doesn't need to know
# about extension subclasses. Combined with the manager's
# exclude_none serialization, missing values stay absent
# from the wire entirely rather than appearing as null.
severity=getattr(decision, "severity", None),
api_key_name=getattr(decision, "api_key_name", None),
memory_unit_id=getattr(decision, "memory_unit_id", None),
receipt_uri=getattr(decision, "receipt_uri", None),
),
)
await webhook_manager.fire_event_with_conn(event, conn, schema=schema)
except Exception:
logger.warning("memory_defense webhook delivery failed", exc_info=True)
def _audit_memory_defense(
audit_logger: Any,
*,
bank_id: str,
document_id: str | None,
decision: DefenseDecision,
) -> None:
"""Write a fire-and-forget ``memory_defense`` audit entry for a non-allow decision.
No-op when audit logging is disabled (the logger gates on its own config).
The action taken (redact/block) and what matched live in the entry metadata.
"""
if audit_logger is None:
return
from ..audit import AuditEntry
entry = AuditEntry(
action="memory_defense",
transport="system",
bank_id=bank_id,
metadata={
"action": decision.action.value,
"detector": decision.detector,
"document_id": document_id,
"matched_types": decision.matched_types,
"message": decision.message,
},
)
entry.ended_at = entry.started_at # point-in-time policy decision (duration 0)
audit_logger.log_fire_and_forget(entry)
def _merge_processed_content_tokens(a: int | None, b: int | None) -> int | None:
"""Combine the processed-content-tokens signal across sub-results.
@@ -306,8 +158,6 @@ def _build_retain_params(contents_dicts, document_tags=None, doc_contents=None):
)
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
if first_item.get("observation_scopes") is not None:
retain_params["observation_scopes"] = first_item["observation_scopes"]
return retain_params, merged_tags
@@ -575,9 +425,6 @@ async def retain_batch(
document_body_override: str | None = None,
chunk_index_offset: int = 0,
progress_callback: "Callable[..., Awaitable[None]] | None" = None,
webhook_manager: Any = None,
memory_defense_extension: "MemoryDefenseExtension | None" = None,
audit_logger: Any = None,
) -> tuple[list[list[str]], TokenUsage, int | None]:
"""
Process a batch of content through the retain pipeline.
@@ -668,10 +515,6 @@ async def retain_batch(
db_semaphore=db_semaphore,
document_body_override=document_body_override,
chunk_index_offset=chunk_index_offset,
progress_callback=progress_callback,
webhook_manager=webhook_manager,
memory_defense_extension=memory_defense_extension,
audit_logger=audit_logger,
)
for group_idx, orig_idx in enumerate(original_indices[doc_key]):
if group_idx < len(group_ids):
@@ -680,80 +523,6 @@ async def retain_batch(
total_processed_tokens = _merge_processed_content_tokens(total_processed_tokens, group_processed)
return result_unit_ids, total_usage, total_processed_tokens
# --- Memory Defense pre-extraction screening ---
# Delegate to the loaded extension. `config` is a resolved HindsightConfig
# object at this point (see _retain_batch_async_internal). On a non-allow
# decision we redact in place or drop the item, and fire a
# memory_defense.triggered webhook when one is configured.
_policy = parse_policy(getattr(config, "memory_defense", None))
_blocked_violations: list[BlockedViolation] = []
if memory_defense_extension is not None and _policy.enabled:
async with acquire_with_retry(pool) as _defense_conn:
for _idx, _content in enumerate(contents):
# Prefer the per-item document_id over the batch-level value so
# the decision and webhook carry the document the caller
# submitted, not whichever doc_id the batch happens to share.
_item_doc_id = contents_dicts[_idx].get("document_id") or document_id
_decision = await memory_defense_extension.screen(
policy=_policy,
bank_id=bank_id,
document_id=_item_doc_id,
content=_content.content,
tags=_content.tags,
)
if _decision.action is DefenseAction.ALLOW:
continue
if _decision.action is DefenseAction.REDACT:
_redacted = _decision.redacted_content or _content.content
_content.content = _redacted
# Mirror the redaction into the raw dict so the document
# body persisted further down the pipeline also stores the
# redacted text, not the verbatim secret.
contents_dicts[_idx]["content"] = _redacted
elif _decision.action is DefenseAction.BLOCK:
_blocked_violations.append(
BlockedViolation(
index=_idx,
detector=_decision.detector,
message=_decision.message,
)
)
await _fire_memory_defense_webhook(
webhook_manager,
conn=_defense_conn,
schema=schema,
bank_id=bank_id,
operation_id=operation_id,
document_id=_item_doc_id,
decision=_decision,
)
_audit_memory_defense(
audit_logger,
bank_id=bank_id,
document_id=_item_doc_id,
decision=_decision,
)
if _blocked_violations:
# All items blocked → raise so the HTTP layer can return 422.
if len(_blocked_violations) == len(contents):
raise MemoryDefenseAllBlockedError(_blocked_violations)
# Remove blocked items from the pipeline.
_skip_indices = {v.index for v in _blocked_violations}
if _skip_indices:
_surviving = [i for i in range(len(contents)) if i not in _skip_indices]
contents = [contents[i] for i in _surviving]
contents_dicts = [contents_dicts[i] for i in _surviving]
# If nothing survives, return empty results immediately.
if not contents:
return [[] for _ in contents_dicts], TokenUsage(), 0
# Resolve effective document_id early so both delta and streaming paths
# can find existing chunks from a prior attempt. On retry, a generated
# document_id is recovered from operation result_metadata.document_ids[0].
@@ -834,28 +603,6 @@ async def retain_batch(
if first.get("tags"):
existing_content["tags"] = first["tags"]
contents_dicts = [existing_content, *contents_dicts]
# Merge JSON arrays to keep original_text valid (#2409).
# Without this, combined_content joins items with "\n", producing
# "[...]\n[...]" which is not valid JSON. On the next append cycle
# chunk_text() fails to parse it and falls through to sentence-
# boundary text splitting, breaking speaker attribution.
try:
_merged = []
for _item in contents_dicts:
_parsed = json.loads(_item.get("content", ""))
if isinstance(_parsed, list) and all(isinstance(_e, dict) for _e in _parsed):
_merged.extend(_parsed)
else:
_merged = None
break
if _merged is not None:
contents_dicts = [{"content": json.dumps(_merged, ensure_ascii=False)}]
if first.get("context"):
contents_dicts[0]["context"] = first["context"]
if first.get("tags"):
contents_dicts[0]["tags"] = first["tags"]
except (json.JSONDecodeError, ValueError, TypeError):
pass
# Rebuild contents list to match
contents = _build_contents(contents_dicts, document_tags)
log_buffer.append(
@@ -920,15 +667,10 @@ async def retain_batch(
# retain code paths.
chunk_batch_size = getattr(config, "retain_chunk_batch_size", 100)
chunk_size = getattr(config, "retain_chunk_size", 3000)
structured_chunk_size = getattr(config, "retain_structured_chunk_size", None)
all_pre_chunks: list[str] = []
chunk_to_content: list[int] = [] # maps chunk index -> index into contents
for content_idx, content in enumerate(contents):
content_chunks = fact_extraction.chunk_text(
content.content,
chunk_size,
structured_chunk_size=structured_chunk_size,
)
content_chunks = fact_extraction.chunk_text(content.content, chunk_size)
all_pre_chunks.extend(content_chunks)
chunk_to_content.extend([content_idx] * len(content_chunks))
@@ -1153,9 +895,7 @@ async def _streaming_retain_batch(
# so documents.original_text stores the complete payload, not just this
# slice (issue #1838).
if document_body_override is not None:
# The override is the unmodified original body — apply redaction so
# secrets in oversized inputs don't bypass screening.
combined_content = _redact_document_body(document_body_override, config)
combined_content = document_body_override
else:
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
# Memory: contents_dicts content strings are now captured in combined_content.
@@ -1637,19 +1377,8 @@ async def _streaming_retain_batch(
# Check if facts are already committed (recovery from previous crash).
# If so, skip extraction+writes and jump straight to final ANN pass.
# ---------------------------------------------------------------------------
# Only the call that starts a document at chunk 0 may take the whole-document
# skip. When an oversized single item is split into several sequential
# sub-batches that SHARE one document_id AND one operation_id (see
# _split_contents_into_sub_batches), the first sub-batch commits its chunks
# and stamps effective_doc_id into result_metadata.facts_committed_document_ids.
# Without the offset gate, every later sub-batch (chunk_index_offset > 0) would
# then see its own document already "committed" and skip extraction, dropping
# all chunks past the first slice. A non-zero offset inherently means this call
# continues a document another sub-batch already started, so it must always do
# its work — crash-safety for those chunks still comes from the per-chunk hash
# recovery (existing_chunk_hashes) below.
facts_already_committed = False
if operation_id and chunk_index_offset == 0:
if operation_id:
try:
async with acquire_with_retry(pool) as conn:
row = await conn.fetchrow(
@@ -1957,7 +1686,6 @@ async def _try_delta_retain(
start_time,
outbox_callback,
document_body_override=document_body_override,
config=config,
)
# Build content items for only the changed/new chunks
@@ -1975,7 +1703,6 @@ async def _try_delta_retain(
start_time,
outbox_callback,
document_body_override=document_body_override,
config=config,
)
# Freshness recheck BEFORE the (expensive) LLM extraction.
@@ -2027,7 +1754,6 @@ async def _try_delta_retain(
start_time,
outbox_callback,
document_body_override=document_body_override,
config=config,
)
log_buffer.append(
f"[delta] Recheck: {len(recheck.changed) + len(recheck.new) + len(recheck.removed)} chunks still differ — "
@@ -2103,10 +1829,9 @@ async def _try_delta_retain(
step_start = time.time()
# When this sub-batch is one slice of an oversized item
# split across multiple sub-batches, store the full body
# (issue #1838) instead of just the slice. Redact the
# override since it bypassed per-chunk screening.
# (issue #1838) instead of just the slice.
if document_body_override is not None:
combined_content = _redact_document_body(document_body_override, config)
combined_content = document_body_override
else:
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
retain_params, merged_tags = _build_retain_params(contents_dicts, document_tags)
@@ -2235,7 +1960,6 @@ async def _delta_metadata_only(
outbox_callback,
*,
document_body_override: str | None = None,
config: Any = None,
):
"""Handle the case where no chunks changed — just update document metadata and tags."""
async with acquire_with_retry(pool) as conn:
@@ -2248,9 +1972,8 @@ async def _delta_metadata_only(
)
# When this sub-batch is a slice of an oversized item, write the
# full original body (issue #1838) instead of just the slice.
# Redact the override since it bypassed per-chunk screening.
if document_body_override is not None:
combined_content = _redact_document_body(document_body_override, config)
combined_content = document_body_override
else:
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
retain_params, merged_tags = _build_retain_params(contents_dicts, document_tags)
@@ -2319,14 +2042,9 @@ def _chunk_contents_for_delta(contents: list[RetainContent], config) -> dict[int
"""
result = {}
global_chunk_idx = 0
chunk_size = getattr(config, "retain_chunk_size", 3000)
structured_chunk_size = getattr(config, "retain_structured_chunk_size", None)
for content in contents:
chunks = fact_extraction.chunk_text(
content.content,
chunk_size,
structured_chunk_size=structured_chunk_size,
)
chunk_size = getattr(config, "retain_chunk_size", 3000)
chunks = fact_extraction.chunk_text(content.content, chunk_size)
for chunk_text in chunks:
result[global_chunk_idx] = chunk_text
global_chunk_idx += 1
@@ -24,9 +24,7 @@ class RetainContentDict(TypedDict, total=False):
tags: Visibility scope tags for this content item (optional)
observation_scopes: How to scope observations for consolidation (optional).
"per_tag" runs one pass per individual tag; "combined" (default) runs a
single pass with all tags; "shared" runs a single pass over one global,
untagged scope so memories consolidate together regardless of tags;
a list[list[str]] specifies exact passes.
single pass with all tags; a list[list[str]] specifies exact passes.
update_mode: How to handle existing documents with the same document_id (optional).
"replace" (default) deletes old data and reprocesses. "append" concatenates
new content to the existing document and reprocesses.
@@ -40,7 +38,7 @@ class RetainContentDict(TypedDict, total=False):
entities: list[dict[str, str]] # [{"text": "...", "type": "..."}]
tags: list[str] # Visibility scope tags
observation_scopes: (
Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]]
Literal["per_tag", "combined", "all_combinations"] | list[list[str]]
) # Observation scopes for consolidation
update_mode: Literal["replace", "append"]
@@ -59,7 +57,7 @@ class RetainContent:
metadata: dict[str, str] = field(default_factory=dict)
entities: list[dict[str, str]] = field(default_factory=list) # User-provided entities
tags: list[str] = field(default_factory=list) # Visibility scope tags
observation_scopes: Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] | None = (
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = (
None # Observation scopes
)
@@ -126,7 +124,7 @@ class ExtractedFact:
mentioned_at: datetime | None = None
metadata: dict[str, str] = field(default_factory=dict)
tags: list[str] = field(default_factory=list) # Visibility scope tags
observation_scopes: Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] | None = (
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = (
None # Observation scopes
)
@@ -178,7 +176,7 @@ class ProcessedFact:
tags: list[str] = field(default_factory=list)
# Observation scopes for consolidation
observation_scopes: Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] | None = None
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = None
@property
def is_duplicate(self) -> bool:
@@ -2,7 +2,9 @@
Helper functions for hybrid search (semantic + BM25 + graph).
"""
from .types import ArmScores, MergedCandidate, RetrievalResult
from typing import Any
from .types import MergedCandidate, RetrievalResult
def cap_per_source(results: list[RetrievalResult], cap: int) -> list[RetrievalResult]:
@@ -51,7 +53,6 @@ def reciprocal_rank_fusion(result_lists: list[list[RetrievalResult]], k: int = 6
rrf_scores = {}
source_ranks = {} # Track rank from each source for each doc_id
all_retrievals = {} # Store the actual RetrievalResult (use first occurrence)
arm_scores: dict[str, ArmScores] = {} # doc_id -> raw per-strategy scores across arms
source_names = ["semantic", "bm25", "graph", "temporal"]
@@ -80,29 +81,17 @@ def reciprocal_rank_fusion(result_lists: list[list[RetrievalResult]], k: int = 6
if doc_id not in rrf_scores:
rrf_scores[doc_id] = 0.0
source_ranks[doc_id] = {}
arm_scores[doc_id] = ArmScores()
rrf_scores[doc_id] += 1.0 / (k + rank)
source_ranks[doc_id][f"{source_name}_rank"] = rank
# Capture this arm's raw score for the doc (the merged RetrievalResult
# below keeps only the first arm's score, so record each arm here).
if source_name == "semantic" and retrieval.similarity is not None:
arm_scores[doc_id].semantic = retrieval.similarity
elif source_name == "bm25" and retrieval.bm25_score is not None:
arm_scores[doc_id].keyword = retrieval.bm25_score
# Combine into final results with metadata
merged_results = []
for rrf_rank, (doc_id, rrf_score) in enumerate(
sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True), start=1
):
merged_candidate = MergedCandidate(
retrieval=all_retrievals[doc_id],
rrf_score=rrf_score,
rrf_rank=rrf_rank,
source_ranks=source_ranks[doc_id],
arm_scores=arm_scores[doc_id],
retrieval=all_retrievals[doc_id], rrf_score=rrf_score, rrf_rank=rrf_rank, source_ranks=source_ranks[doc_id]
)
merged_results.append(merged_candidate)
@@ -131,7 +120,6 @@ def interleave_fusion(result_lists: list[list[RetrievalResult]]) -> list[MergedC
source_names = ["semantic", "bm25", "graph", "temporal"]
source_ranks: dict[str, dict[str, int]] = {}
all_retrievals: dict[str, RetrievalResult] = {}
arm_scores: dict[str, ArmScores] = {}
for source_idx, results in enumerate(result_lists):
source_name = source_names[source_idx] if source_idx < len(source_names) else f"source_{source_idx}"
@@ -143,11 +131,6 @@ def interleave_fusion(result_lists: list[list[RetrievalResult]]) -> list[MergedC
doc_id = retrieval.id
all_retrievals.setdefault(doc_id, retrieval)
source_ranks.setdefault(doc_id, {})[f"{source_name}_rank"] = rank
arm = arm_scores.setdefault(doc_id, ArmScores())
if source_name == "semantic" and retrieval.similarity is not None:
arm.semantic = retrieval.similarity
elif source_name == "bm25" and retrieval.bm25_score is not None:
arm.keyword = retrieval.bm25_score
# Round-robin pick across arms in priority order: all #1s, then all #2s, ...
ordered_ids: list[str] = []
@@ -170,7 +153,42 @@ def interleave_fusion(result_lists: list[list[RetrievalResult]]) -> list[MergedC
rrf_score=float(n - pos),
rrf_rank=pos + 1,
source_ranks=source_ranks[doc_id],
arm_scores=arm_scores[doc_id],
)
for pos, doc_id in enumerate(ordered_ids)
]
def normalize_scores_on_deltas(results: list[dict[str, Any]], score_keys: list[str]) -> list[dict[str, Any]]:
"""
Normalize scores based on deltas (min-max normalization within result set).
This ensures all scores are in [0, 1] range based on the spread in THIS result set.
Args:
results: List of result dicts
score_keys: Keys to normalize (e.g., ["recency", "frequency"])
Returns:
Results with normalized scores added as "{key}_normalized"
"""
for key in score_keys:
values = [r.get(key, 0.0) for r in results if key in r]
if not values:
continue
min_val = min(values)
max_val = max(values)
delta = max_val - min_val
if delta > 0:
for r in results:
if key in r:
r[f"{key}_normalized"] = (r[key] - min_val) / delta
else:
# All values are the same, set to 0.5
for r in results:
if key in r:
r[f"{key}_normalized"] = 0.5
return results
@@ -251,10 +251,8 @@ class LinkExpansionRetriever(GraphRetriever):
result.activation = row["score"]
results.append(result)
# filter_results_by_tags is a no-op when no filter applies (tags falsy and not
# the exact-empty/global scope), so call it unconditionally — gating on `if tags:`
# would skip the untagged-only filter for tags=[] + tags_match="exact".
results = filter_results_by_tags(results, tags, match=tags_match)
if tags:
results = filter_results_by_tags(results, tags, match=tags_match)
if tag_groups:
results = filter_results_by_tag_groups(results, tag_groups)
@@ -16,44 +16,6 @@ _RECENCY_ALPHA: float = 0.2
_TEMPORAL_ALPHA: float = 0.2
_PROOF_COUNT_ALPHA: float = 0.1 # Conservative: max ±5% for evidence strength
# Recency decay: maps a memory's age (days) onto a freshness signal in [0, 1]
# where 0.5 is neutral (no boost). The signal is then folded into the
# multiplicative recency_boost via `1 + recency_alpha * (recency - 0.5)`.
#
# "linear" — straight line from 1.0 (today) to a floor of 0.1, reaching
# the floor at `linear_window_days`. The historical default.
# "exponential" — 0.5 ** (days_ago / halflife_days). The half-life is the age
# at which the signal is exactly neutral (0.5): younger
# memories are boosted, older ones penalised, with a smooth
# asymptote toward 0 (no hard cutoff).
# "none" — always neutral (0.5), disabling the recency boost entirely.
# The validated set of names lives in config.RECENCY_DECAY_FUNCTIONS.
_RECENCY_DECAY_FUNCTION: str = "linear"
_RECENCY_DECAY_LINEAR_WINDOW_DAYS: float = 365.0
_RECENCY_DECAY_HALFLIFE_DAYS: float = 90.0
def compute_recency_decay(
days_ago: float,
function: str = _RECENCY_DECAY_FUNCTION,
linear_window_days: float = _RECENCY_DECAY_LINEAR_WINDOW_DAYS,
halflife_days: float = _RECENCY_DECAY_HALFLIFE_DAYS,
) -> float:
"""Map a memory's age in days to a freshness signal in [0, 1] (neutral 0.5).
Future-dated memories (negative ``days_ago``) clamp to the maximum freshness
so they are never penalised. See ``RECENCY_DECAY_FUNCTIONS`` for the shapes.
"""
if function == "none":
return 0.5
if function == "exponential":
if halflife_days <= 0:
return 0.5
return min(1.0, 0.5 ** (days_ago / halflife_days))
# "linear" (default): straight decay to a 0.1 floor over the window.
window = linear_window_days if linear_window_days > 0 else _RECENCY_DECAY_LINEAR_WINDOW_DAYS
return max(0.1, min(1.0, 1.0 - (days_ago / window)))
def apply_combined_scoring(
scored_results: list[ScoredResult],
@@ -62,9 +24,6 @@ def apply_combined_scoring(
temporal_alpha: float = _TEMPORAL_ALPHA,
proof_count_alpha: float = _PROOF_COUNT_ALPHA,
is_passthrough_reranker: bool = False,
recency_decay_function: str = _RECENCY_DECAY_FUNCTION,
recency_decay_linear_window_days: float = _RECENCY_DECAY_LINEAR_WINDOW_DAYS,
recency_decay_halflife_days: float = _RECENCY_DECAY_HALFLIFE_DAYS,
) -> None:
"""Apply combined scoring to a list of ScoredResults in-place.
@@ -98,12 +57,6 @@ def apply_combined_scoring(
recency_alpha: Max relative recency adjustment (default 0.2 ±10%).
temporal_alpha: Max relative temporal adjustment (default 0.2 ±10%).
proof_count_alpha: Max relative proof count adjustment (default 0.1 ±5%).
recency_decay_function: Agefreshness curve "linear" (default),
"exponential", or "none". See compute_recency_decay.
recency_decay_linear_window_days: Days over which the linear curve
decays to its floor (default 365).
recency_decay_halflife_days: For the exponential curve, the age at which
the recency signal is neutral (0.5) (default 90).
"""
if now.tzinfo is None:
now = now.replace(tzinfo=UTC)
@@ -145,26 +98,14 @@ def apply_combined_scoring(
sr.cross_encoder_score_normalized = 1.0 - (0.9 * new_rank / denom)
for sr in scored_results:
# Recency: configurable decay (linear default; see compute_recency_decay)
# → [0.0, 1.0]; neutral 0.5 if no date.
# Use the unit's effective time (occurred_start, then mentioned_at, then
# occurred_end) — the same COALESCE order as retrieval._coalesce_date — so a
# memory that carries only a mentioned_at / occurred_end (e.g. conversation
# facts or ongoing states that intentionally lack occurred_start) still gets
# correct recency ordering instead of a flat neutral 0.5.
# Recency: linear decay over 365 days → [0.1, 1.0]; neutral 0.5 if no date.
sr.recency = 0.5
effective = sr.retrieval.occurred_start or sr.retrieval.mentioned_at or sr.retrieval.occurred_end
if effective:
occurred = effective
if sr.retrieval.occurred_start:
occurred = sr.retrieval.occurred_start
if occurred.tzinfo is None:
occurred = occurred.replace(tzinfo=UTC)
days_ago = (now - occurred).total_seconds() / 86400
sr.recency = compute_recency_decay(
days_ago,
recency_decay_function,
recency_decay_linear_window_days,
recency_decay_halflife_days,
)
sr.recency = max(0.1, min(1.0, 1.0 - (days_ago / 365)))
# Temporal proximity: meaningful only for temporal queries; neutral otherwise.
sr.temporal = sr.retrieval.temporal_proximity if sr.retrieval.temporal_proximity is not None else 0.5
@@ -177,9 +118,6 @@ def apply_combined_scoring(
else:
# Neutral baseline is precisely 0.5, ensuring neutral multiplier (1.0)
proof_norm = 0.5
# Surface the proof signal so the trace can show the proof_count_boost
# factor (otherwise the reranked breakdown can't reconcile CE × boosts).
sr.proof_norm = proof_norm
# RRF: kept at 0.0 for trace continuity but excluded from scoring.
# RRF is batch-relative (min-max normalised) and redundant after reranking.
@@ -13,7 +13,7 @@ import logging
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Optional
from typing import Any, Optional
from ...config import get_config
from ..db_utils import acquire_with_retry
@@ -24,9 +24,6 @@ from .link_expansion_retrieval import LinkExpansionRetriever
from .tags import TagGroup, TagsMatch, build_tag_groups_where_clause, build_tags_where_clause_simple
from .types import GraphRetrievalTimings, RetrievalResult
if TYPE_CHECKING:
from ..query_analyzer import QueryAnalyzer
logger = logging.getLogger(__name__)
@@ -104,8 +101,6 @@ async def retrieve_semantic_bm25_combined(
tag_groups: list[TagGroup] | None = None,
created_after: datetime | None = None,
created_before: datetime | None = None,
min_semantic: float | None = None,
min_keyword: float | None = None,
) -> dict[str, tuple[list[RetrievalResult], list[RetrievalResult]]]:
"""
Combined semantic + BM25 retrieval for multiple fact types in a single query.
@@ -145,12 +140,6 @@ async def retrieve_semantic_bm25_combined(
config = get_config()
tokens = tokenize_query(query_text)
# Per-request retrieval-level score floors (recall min_scores.semantic / .keyword)
# override the global config defaults for this query, pruning weak matches in
# the SQL arms before fusion.
sem_min = min_semantic if min_semantic is not None else config.semantic_min_similarity
bm25_min = min_keyword if min_keyword is not None else config.bm25_min_score
# Over-fetch for HNSW approximation; semantic results trimmed to limit in Python.
hnsw_fetch = max(limit * 5, 100)
@@ -211,7 +200,7 @@ async def retrieve_semantic_bm25_combined(
embedding_param="$1",
bank_id_param="$2",
fetch_limit=hnsw_fetch,
min_similarity=sem_min,
min_similarity=config.semantic_min_similarity,
tags_clause=tags_clause,
groups_clause=groups_clause,
extra_where=created_range_clause,
@@ -237,7 +226,7 @@ async def retrieve_semantic_bm25_combined(
arm_index=i,
text_search_extension=text_ext,
bm25_language=config.text_search_extension_native_language,
bm25_min_score=bm25_min,
bm25_min_score=config.bm25_min_score,
extra_where=created_range_clause,
)
)
@@ -285,7 +274,7 @@ async def retrieve_semantic_bm25_combined(
embedding_param="$1",
bank_id_param="$2",
fetch_limit=hnsw_fetch,
min_similarity=sem_min,
min_similarity=config.semantic_min_similarity,
tags_clause=fb_tags_clause,
groups_clause=fb_groups_clause,
extra_where=fb_created_clause,
@@ -714,8 +703,6 @@ async def retrieve_all_fact_types_parallel(
tag_groups: list[TagGroup] | None = None,
created_after: datetime | None = None,
created_before: datetime | None = None,
min_semantic: float | None = None,
min_keyword: float | None = None,
) -> MultiFactTypeRetrievalResult:
"""
Optimized retrieval for multiple fact types using batched queries.
@@ -776,8 +763,6 @@ async def retrieve_all_fact_types_parallel(
tag_groups=tag_groups,
created_after=created_after,
created_before=created_before,
min_semantic=min_semantic,
min_keyword=min_keyword,
)
semantic_bm25_time = time.time() - semantic_bm25_start
@@ -793,7 +778,7 @@ async def retrieve_all_fact_types_parallel(
tc_start,
tc_end,
budget=thinking_budget,
semantic_threshold=min_semantic if min_semantic is not None else 0.1,
semantic_threshold=0.1,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
@@ -2,24 +2,14 @@
Tags filtering utilities for retrieval.
Provides SQL building functions for filtering memories by tags.
Supports five matching modes via TagsMatch enum:
Supports four matching modes via TagsMatch enum:
- "any": OR matching, includes untagged memories (default, backward compatible)
- "all": AND matching, includes untagged memories
- "any_strict": OR matching, excludes untagged memories
- "all_strict": AND matching, excludes untagged memories
- "exact": set-equality matching, excludes untagged memories
OR matching (any/any_strict): Memory matches if ANY of its tags overlap with request tags
AND matching (all/all_strict): Memory matches if ALL request tags are present in its tags
EXACT matching: Memory matches only if its tag set EQUALS the request tag set (order-
independent). Used for observation "scope" filtering, where each observation lives
under exactly one scope (its full tag set) and "scope [a]" must not match "[a, b]".
An EMPTY request scope (no tags ``[]`` or ``None``) is the global/untagged scope and
matches only untagged memories the scope that ``observation_scopes="shared"``
consolidation writes to. This is the one mode where absent tags filter rather than
meaning "no filter"; all other modes treat empty/absent tags as "no filtering". This
mirrors the ``GET .../graph`` endpoint, where ``tags_match="exact"`` with no tags also
selects the global scope.
"""
from __future__ import annotations
@@ -28,7 +18,7 @@ from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field
TagsMatch = Literal["any", "all", "any_strict", "all_strict", "exact"]
TagsMatch = Literal["any", "all", "any_strict", "all_strict"]
def _parse_tags_match(match: TagsMatch) -> tuple[str, bool]:
@@ -48,10 +38,6 @@ def _parse_tags_match(match: TagsMatch) -> tuple[str, bool]:
return "&&", False
elif match == "all_strict":
return "@>", False
elif match == "exact":
# Set equality is handled by the callers via `@> AND <@`; the operator
# here is unused. Untagged rows never equal a non-empty scope.
return "@>", False
else:
# Default to "any" behavior
return "&&", True
@@ -88,22 +74,10 @@ def build_tags_where_clause(
>>> clause, params, next_offset = build_tags_where_clause(['user_a'], 3, 'mu.', 'any_strict')
>>> print(clause) # "AND mu.tags IS NOT NULL AND mu.tags != '{}' AND mu.tags && $3"
"""
column = f"{table_alias}tags" if table_alias else "tags"
if match == "exact" and not tags:
# Empty/absent scope = global/untagged: match only untagged rows. No bind param
# needed (callers gate the param on truthy `tags`, so none is appended).
return f"AND ({column} IS NULL OR {column} = '{{}}')", [], param_offset
if not tags:
return "", [], param_offset
if match == "exact":
# Set equality (order-independent): superset AND subset. Untagged rows
# (empty array) never satisfy `@>` of a non-empty scope, so they're excluded.
clause = f"AND ({column} @> ${param_offset} AND {column} <@ ${param_offset})"
return clause, [tags], param_offset + 1
column = f"{table_alias}tags" if table_alias else "tags"
operator, include_untagged = _parse_tags_match(match)
if include_untagged:
@@ -137,21 +111,10 @@ def build_tags_where_clause_simple(
Returns:
SQL clause string or empty string.
"""
column = f"{table_alias}tags" if table_alias else "tags"
if match == "exact" and not tags:
# Empty/absent scope = global/untagged: match only untagged rows. No bind param
# needed (callers gate the param on truthy `tags`, so none is appended).
return f"AND ({column} IS NULL OR {column} = '{{}}')"
if not tags:
return ""
if match == "exact":
# Set equality (order-independent): superset AND subset. Untagged rows
# (empty array) never satisfy `@>` of a non-empty scope, so they're excluded.
return f"AND ({column} @> ${param_num} AND {column} <@ ${param_num})"
column = f"{table_alias}tags" if table_alias else "tags"
operator, include_untagged = _parse_tags_match(match)
if include_untagged:
@@ -180,10 +143,6 @@ def filter_results_by_tags(
Returns:
Filtered list of results.
"""
if match == "exact" and not tags:
# Empty/absent scope = global/untagged: keep only untagged results.
return [r for r in results if not getattr(r, "tags", None)]
if not tags:
return results
@@ -205,11 +164,7 @@ def filter_results_by_tags(
# else: skip untagged
else:
result_tags_set = set(result_tags)
if match == "exact":
# Set equality: tag set must match the scope exactly
if result_tags_set == tags_set:
filtered.append(result)
elif is_any_match:
if is_any_match:
# Any overlap
if result_tags_set & tags_set:
filtered.append(result)
@@ -286,12 +241,6 @@ def _build_group_clause(
"""
if isinstance(group, TagGroupLeaf):
column = f"{table_alias}tags" if table_alias else "tags"
if group.match == "exact":
if len(group.tags) == 0:
# Empty scope = global/untagged: match only untagged rows (no bind param).
return f"({column} IS NULL OR {column} = '{{}}')", [], param_offset
clause = f"({column} @> ${param_offset} AND {column} <@ ${param_offset})"
return clause, [group.tags], param_offset + 1
operator, include_untagged = _parse_tags_match(group.match)
if include_untagged:
clause = f"({column} IS NULL OR {column} = '{{}}' OR {column} {operator} ${param_offset})"
@@ -392,9 +341,6 @@ def _match_group(result: object, group: TagGroup) -> bool:
if isinstance(group, TagGroupLeaf):
result_tags = getattr(result, "tags", None)
is_untagged = result_tags is None or len(result_tags) == 0
if group.match == "exact" and len(group.tags) == 0:
# Empty scope = global/untagged: match only untagged results.
return is_untagged
_, include_untagged = _parse_tags_match(group.match)
is_any_match = group.match in ("any", "any_strict")
tags_set = set(group.tags)
@@ -403,8 +349,6 @@ def _match_group(result: object, group: TagGroup) -> bool:
return include_untagged
else:
result_tags_set = set(result_tags)
if group.match == "exact":
return result_tags_set == tags_set
if is_any_match:
return bool(result_tags_set & tags_set)
else:
@@ -5,7 +5,6 @@ Think operation utilities for formulating answers based on agent and world facts
import logging
from datetime import datetime
from ...config import get_config
from ..response_models import DispositionTraits, MemoryFact
logger = logging.getLogger(__name__)
@@ -252,7 +251,7 @@ async def reflect(
answer_text = await llm_config.call(
messages=[{"role": "system", "content": system_message}, {"role": "user", "content": prompt}],
scope="memory_think",
temperature=get_config().llm_temperature_reflect,
temperature=0.9,
max_completion_tokens=1000,
)
@@ -392,7 +392,7 @@ class SearchTracer:
# Extract score components (only include non-None values)
# Keys from ScoredResult.to_dict(): cross_encoder_score, cross_encoder_score_normalized,
# rrf_normalized, temporal, recency, proof_norm, combined_score, weight
# rrf_normalized, temporal, recency, combined_score, weight
score_components = {}
for key in [
"cross_encoder_score",
@@ -401,7 +401,6 @@ class SearchTracer:
"rrf_normalized",
"temporal",
"recency",
"proof_norm",
"combined_score",
]:
if key in result and result[key] is not None:
@@ -82,20 +82,6 @@ class RetrievalResult:
)
@dataclass
class ArmScores:
"""Raw per-strategy retrieval scores for a single doc, aggregated across arms.
Fusion keeps only the first-seen RetrievalResult per doc, so its per-arm score
fields reflect just one arm. This captures each arm's raw score for the same doc
so the recall response can report them (and ``min_scores`` can filter on them).
``None`` means the doc was not surfaced by that arm.
"""
semantic: float | None = None # cosine similarity from the semantic arm
keyword: float | None = None # BM25 / full-text score from the keyword arm
@dataclass
class MergedCandidate:
"""
@@ -111,7 +97,6 @@ class MergedCandidate:
rrf_score: float
rrf_rank: int = 0
source_ranks: dict[str, int] = field(default_factory=dict) # method_name -> rank
arm_scores: "ArmScores" = field(default_factory=lambda: ArmScores()) # raw per-strategy scores
@property
def id(self) -> str:
@@ -138,7 +123,6 @@ class ScoredResult:
rrf_normalized: float = 0.0
recency: float = 0.5
temporal: float = 0.5
proof_norm: float = 0.5 # log-normalized proof count (neutral 0.5); drives proof_count_boost
# Final combined score
combined_score: float = 0.0
@@ -195,7 +179,6 @@ class ScoredResult:
result["rrf_normalized"] = self.rrf_normalized
result["temporal"] = self.temporal
result["recency"] = self.recency
result["proof_norm"] = self.proof_norm
result["combined_score"] = self.combined_score
result["weight"] = self.weight
result["activation"] = self.weight # Legacy field
@@ -2,7 +2,7 @@
import logging
import os
from datetime import timedelta, timezone
from datetime import datetime, timedelta, timezone
import obstore as obs
from obstore.store import GCSStore

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