Compare commits

..
Author SHA1 Message Date
Nicolò Boschi 105df790cf fix(hindsight-embed): restore __file__-relative fallback for --target installs
sysconfig.get_path("scripts") correctly fixes stock venv installs
(#1401) but doesn't cover `pip install --target` layouts where the
binary sits alongside site-packages contents. Keep the original
Path(__file__)-based lookup as a second fallback before uvx (#1240).
2026-05-04 15:24:00 +02:00
Nicolò Boschi 27cdb16d68 fix(typescript-client): skip TestAbortSignal under Deno
Deno freezes ES module namespace objects, so jest.spyOn cannot patch
sdk exports. Skip these spy-based unit tests under Deno (they're
already covered by the Jest suite).
2026-05-04 12:59:27 +02:00
Nicolò Boschi 333c385452 fix(typescript-client): add jest.spyOn/fn shim to deno_setup.ts
The TestAbortSignal tests use jest.spyOn which doesn't exist under Deno.
Add a mock implementation (matching the pattern in the AI SDK's
vitest-compat.ts) so these tests pass with deno test.
2026-05-04 12:54:15 +02:00
Nicolò Boschi 8ed8dbe795 fix(hindsight-embed): use sysconfig to find scripts dir in _find_api_command (#1401)
`Path(__file__).parent.parent` resolves to site-packages/ in stock pip
venvs, missing the actual scripts dir (<venv>/bin or <venv>/Scripts).
Use `sysconfig.get_path("scripts")` which works across pip venvs, conda,
and --target installs.
2026-05-04 12:37:03 +02:00
702 changed files with 19173 additions and 46393 deletions
+2 -33
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, volcano
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai, minimax, deepseek, volcano
HINDSIGHT_API_LLM_PROVIDER=openai
HINDSIGHT_API_LLM_API_KEY=your-api-key-here
HINDSIGHT_API_LLM_MODEL=gpt-4o-mini
@@ -30,11 +30,6 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# HINDSIGHT_API_LLM_API_KEY=your-deepseek-api-key
# HINDSIGHT_API_LLM_MODEL=deepseek-v4-flash # or deepseek-v4-pro / deepseek-chat / deepseek-reasoner
# Example: z.ai configuration (Zhipu GLM series, https://z.ai)
# HINDSIGHT_API_LLM_PROVIDER=zai
# 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: LM Studio local configuration (Qwen 2.5 32B recommended)
# HINDSIGHT_API_LLM_PROVIDER=lmstudio
# HINDSIGHT_API_LLM_API_KEY=lmstudio
@@ -54,7 +49,6 @@ HINDSIGHT_API_LOG_LEVEL=info
# Database (Optional - uses embedded pg0 by default)
# HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/db
# 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)
@@ -65,25 +59,12 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_VECTOR_EXTENSION=pgvectorscale # Auto-detects pg_diskann on Azure
# Embeddings Configuration (Optional - uses local by default)
# Provider: "local" (default), "tei", "openai", "cohere", "google", "openrouter", "litellm", or "litellm-sdk"
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
# For local provider:
# HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-small-en-v1.5
# Optional for China network / restricted HF access:
# HF_ENDPOINT=https://hf-mirror.com
# For TEI provider:
# HINDSIGHT_API_EMBEDDINGS_TEI_URL=http://localhost:8080
# For OpenAI-compatible embeddings:
# HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=sk-xxxx
# HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL=text-embedding-3-small
# HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL=https://api.openai.com/v1
#
# IMPORTANT: Embedding keys require provider-specific names:
# HINDSIGHT_API_EMBEDDINGS_{PROVIDER}_{PARAMETER}
# (for example, HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL).
#
# DeepSeek note: DeepSeek is supported for LLM calls, but not for embeddings.
# If using DeepSeek as LLM provider, keep embeddings on local/openai/cohere/google/etc.
# Reranker Configuration (Optional - uses local by default)
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
@@ -107,15 +88,3 @@ 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
# -----------------------------------------------------------------------------
# Control Plane (Optional)
# -----------------------------------------------------------------------------
# Dataplane API URL - where the CP proxies requests to
# HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:8888
# Optional: Require a shared access key to view the Control Plane UI.
# When set, visitors see a login page and must enter the key before
# accessing the dashboard or any /api/* routes (except /api/health).
# HINDSIGHT_CP_ACCESS_KEY=your-shared-secret-key
+37 -40
View File
@@ -25,10 +25,10 @@ on:
- recall-with-observations
- consolidation
default: ""
locomo_conversations:
description: "LoComo conversation IDs (space-separated). Blank = curated set (conv-26 conv-30 conv-43)."
type: string
default: ""
locomo_max_conversations:
description: "LoComo max conversations (0 = skip, blank = all)"
type: number
default: 0
locomo_skip:
description: "Skip LoComo job"
type: boolean
@@ -83,36 +83,46 @@ jobs:
run: |
cd hindsight-dev && uv sync --frozen --all-extras --index-strategy unsafe-best-match
- name: Run perf-test
- name: "Suite: retain"
if: inputs.suite == '' || inputs.suite == 'retain'
run: |
SUITE_ARG=""
if [ -n "${{ inputs.suite }}" ]; then
SUITE_ARG="--suite ${{ inputs.suite }}"
fi
./scripts/benchmarks/run-perf-test.sh \
--scale ${{ inputs.scale || 'large' }} \
$SUITE_ARG \
--output perf-results.json
--suite retain \
--output perf-results-retain.json
- name: "Suite: recall"
if: inputs.suite == '' || inputs.suite == 'recall'
run: |
./scripts/benchmarks/run-perf-test.sh \
--scale ${{ inputs.scale || 'large' }} \
--suite recall \
--output perf-results-recall.json
- name: "Suite: recall-with-observations"
if: inputs.suite == '' || inputs.suite == 'recall-with-observations'
run: |
./scripts/benchmarks/run-perf-test.sh \
--scale ${{ inputs.scale || 'large' }} \
--suite recall-with-observations \
--output perf-results-recall-with-observations.json
- name: "Suite: consolidation"
if: inputs.suite == '' || inputs.suite == 'consolidation'
run: |
./scripts/benchmarks/run-perf-test.sh \
--scale ${{ inputs.scale || 'large' }} \
--suite consolidation \
--output perf-results-consolidation.json
- name: Upload perf results
if: always()
uses: actions/upload-artifact@v7
with:
name: perf-results-${{ github.sha }}
path: hindsight-dev/perf-results.json
path: hindsight-dev/perf-results-*.json
retention-days: 90
# Publish enriched results (perf JSON + commit metadata) to the dashboard
# repo's gh-pages branch. The static site at
# https://vectorize-io.github.io/hindsight-continuous-performance-monitor/
# reads data/index.json + data/<run>.json and renders charts client-side.
- name: Publish to dashboard
if: github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'workflow_dispatch'
env:
PERF_DASHBOARD_TOKEN: ${{ secrets.PERF_DASHBOARD_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./scripts/benchmarks/publish-perf-results.sh hindsight-dev/perf-results.json
locomo:
if: inputs.locomo_skip != true
runs-on: ubuntu-latest
@@ -169,20 +179,14 @@ jobs:
cd hindsight-dev && uv sync --frozen --all-extras --index-strategy unsafe-best-match
- name: Run LoComo benchmark
# Curated 3-conversation subset (best/middle/worst by accuracy on the
# last successful full run): conv-26 (best), conv-30 (middle), conv-43
# (worst). Excludes conv-44, the bank with the largest unconsolidated
# set that has been pushing scheduled runs over the per-bank
# _wait_for_consolidation timeout. Override via workflow_dispatch with
# the locomo_conversations input.
run: |
CONVERSATIONS="${{ inputs.locomo_conversations }}"
if [ -z "$CONVERSATIONS" ]; then
CONVERSATIONS="conv-26 conv-30 conv-43"
MAX_CONV_ARG=""
if [ "${{ inputs.locomo_max_conversations }}" != "0" ] && [ -n "${{ inputs.locomo_max_conversations }}" ]; then
MAX_CONV_ARG="--max-conversations ${{ inputs.locomo_max_conversations }}"
fi
uv run python hindsight-dev/benchmarks/locomo/locomo_benchmark.py \
--wait-consolidation \
--conversation $CONVERSATIONS
$MAX_CONV_ARG
- name: Upload LoComo results
if: always()
@@ -191,10 +195,3 @@ jobs:
name: locomo-results-${{ github.sha }}
path: hindsight-dev/benchmarks/locomo/results/
retention-days: 90
- name: Publish LoComo to dashboard
if: success() && (github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'workflow_dispatch')
env:
PERF_DASHBOARD_TOKEN: ${{ secrets.PERF_DASHBOARD_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./scripts/benchmarks/publish-locomo-results.sh hindsight-dev/benchmarks/locomo/results/benchmark_results.json
+1 -1
View File
@@ -117,7 +117,7 @@ jobs:
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
run: |
set +e
OUTPUT=$(npm publish --access public --provenance 2>&1)
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
-27
View File
@@ -314,7 +314,6 @@ jobs:
permissions:
contents: read
packages: write
id-token: write
strategy:
matrix:
include:
@@ -411,7 +410,6 @@ jobs:
# Build multi-platform and push to release tags
- name: Build and push release images
id: build
uses: docker/build-push-action@v7
with:
context: .
@@ -423,31 +421,6 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Sign published images
env:
TAGS: ${{ steps.meta.outputs.tags }}
DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
refs=()
while IFS= read -r tag; do
[[ -z "${tag}" ]] && continue
refs+=("${tag}@${DIGEST}")
done <<< "${TAGS}"
cosign sign --yes "${refs[@]}"
- name: Verify signature on primary tag
env:
IMAGE: ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}
DIGEST: ${{ steps.build.outputs.digest }}
run: |
cosign verify "${IMAGE}@${DIGEST}" \
--certificate-identity-regexp "^https://github\.com/${{ github.repository }}/\.github/workflows/release\.yml@.*" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
release-helm-chart:
runs-on: ubuntu-latest
permissions:
-71
View File
@@ -1,71 +0,0 @@
name: Sign published images
on:
workflow_dispatch:
inputs:
version:
description: 'Version to sign (without leading v, e.g. 0.6.0)'
required: true
type: string
default: '0.6.0'
permissions:
contents: read
packages: write
id-token: write
jobs:
sign:
name: Sign ${{ matrix.image }}:${{ inputs.version }}${{ matrix.suffix }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { image: hindsight-api, suffix: '' }
- { image: hindsight-api, suffix: '-slim' }
- { image: hindsight-control-plane, suffix: '' }
- { image: hindsight, suffix: '' }
- { image: hindsight, suffix: '-slim' }
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Resolve image digest
id: resolve
env:
IMAGE: ghcr.io/${{ github.repository_owner }}/${{ matrix.image }}
TAG: ${{ inputs.version }}${{ matrix.suffix }}
run: |
set -euo pipefail
DIGEST=$(docker buildx imagetools inspect "${IMAGE}:${TAG}" --format '{{json .Manifest.Digest}}' | tr -d '"')
if [[ -z "${DIGEST}" || "${DIGEST}" != sha256:* ]]; then
echo "Failed to resolve digest for ${IMAGE}:${TAG} (got: ${DIGEST})" >&2
exit 1
fi
echo "Resolved ${IMAGE}:${TAG} -> ${DIGEST}"
echo "ref=${IMAGE}@${DIGEST}" >> "$GITHUB_OUTPUT"
- name: Sign image
env:
REF: ${{ steps.resolve.outputs.ref }}
run: cosign sign --yes "${REF}"
- name: Verify signature
env:
REF: ${{ steps.resolve.outputs.ref }}
run: |
cosign verify "${REF}" \
--certificate-identity-regexp "^https://github\.com/${{ github.repository }}/\.github/workflows/sign-images\.yml@.*" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
+118 -199
View File
@@ -3,6 +3,8 @@ name: CI
on:
pull_request:
branches: [ main ]
pull_request_review:
types: [ submitted ]
workflow_dispatch:
concurrency:
@@ -11,6 +13,10 @@ concurrency:
jobs:
detect-changes:
# Skip non-approved pull_request_review events
if: >-
github.event_name != 'pull_request_review' ||
github.event.review.state == 'approved'
runs-on: ubuntu-latest
permissions:
pull-requests: read
@@ -41,22 +47,24 @@ jobs:
integrations-llamaindex: ${{ steps.filter.outputs.integrations-llamaindex }}
integrations-paperclip: ${{ steps.filter.outputs.integrations-paperclip }}
integrations-opencode: ${{ steps.filter.outputs.integrations-opencode }}
integrations-n8n: ${{ steps.filter.outputs.integrations-n8n }}
integrations-cloudflare-oauth-proxy: ${{ steps.filter.outputs.integrations-cloudflare-oauth-proxy }}
integrations-lockfiles: ${{ steps.filter.outputs.integrations-lockfiles }}
integrations-openai-agents: ${{ steps.filter.outputs.integrations-openai-agents }}
integrations-pipecat: ${{ steps.filter.outputs.integrations-pipecat }}
integrations-agentcore: ${{ steps.filter.outputs.integrations-agentcore }}
integrations-smolagents: ${{ steps.filter.outputs.integrations-smolagents }}
integrations-dify: ${{ steps.filter.outputs.integrations-dify }}
tools-agent-sdk: ${{ steps.filter.outputs.tools-agent-sdk }}
tools-self-driving-agents: ${{ steps.filter.outputs.tools-self-driving-agents }}
dev: ${{ steps.filter.outputs.dev }}
ci: ${{ steps.filter.outputs.ci }}
# Secrets are available for internal PRs and workflow_dispatch.
# Secrets are available for internal PRs, pull_request_review, and workflow_dispatch.
# Fork PRs via pull_request event do NOT have access to secrets.
has_secrets: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
steps:
- uses: actions/checkout@v6
with:
# For pull_request_review, checkout the PR head (not the base branch)
ref: ${{ github.event_name == 'pull_request_review' && github.event.pull_request.head.sha || '' }}
- uses: dorny/paths-filter@v4
id: filter
@@ -125,8 +133,6 @@ jobs:
- 'hindsight-integrations/paperclip/**'
integrations-opencode:
- 'hindsight-integrations/opencode/**'
integrations-n8n:
- 'hindsight-integrations/n8n/**'
integrations-cloudflare-oauth-proxy:
- 'hindsight-integrations/cloudflare-oauth-proxy/**'
integrations-lockfiles:
@@ -141,10 +147,10 @@ jobs:
- 'hindsight-integrations/agentcore/**'
integrations-smolagents:
- 'hindsight-integrations/smolagents/**'
integrations-dify:
- 'hindsight-integrations/dify/**'
tools-agent-sdk:
- 'hindsight-tools/hindsight-agent-sdk/**'
tools-self-driving-agents:
- 'hindsight-tools/self-driving-agents/**'
dev:
- 'hindsight-dev/**'
ci:
@@ -160,6 +166,7 @@ jobs:
check-integration-lockfiles:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-lockfiles == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -175,6 +182,7 @@ jobs:
build-api-python-versions:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -205,6 +213,7 @@ jobs:
build-typescript-client:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -231,6 +240,7 @@ jobs:
build-hindsight-all-npm:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.all-npm == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -260,6 +270,7 @@ jobs:
build-openclaw-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-openclaw == 'true' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
@@ -315,6 +326,7 @@ jobs:
smoke-openclaw-install:
needs: [detect-changes, build-openclaw-integration]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-openclaw == 'true' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
@@ -372,6 +384,7 @@ jobs:
test-claude-code-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-claude-code == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -397,6 +410,7 @@ jobs:
test-codex-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-codex == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -422,6 +436,7 @@ jobs:
build-ai-sdk-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-ai-sdk == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -452,6 +467,7 @@ jobs:
test-ai-sdk-integration-deno:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-ai-sdk == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -483,6 +499,7 @@ jobs:
test-opencode-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-opencode == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -510,39 +527,10 @@ jobs:
working-directory: ./hindsight-integrations/opencode
run: npm run build
test-n8n-integration:
needs: [detect-changes]
if: >-
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-n8n == '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/n8n
run: npm install --no-fund --no-audit
- name: Run tests
working-directory: ./hindsight-integrations/n8n
run: npm test
- name: Build
working-directory: ./hindsight-integrations/n8n
run: npm run build
test-hindsight-agent-sdk:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.tools-agent-sdk == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -572,9 +560,43 @@ jobs:
- name: Build
run: npm run build --workspace=hindsight-tools/hindsight-agent-sdk
test-self-driving-agents:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.tools-self-driving-agents == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install root workspace dependencies
run: npm ci
- name: Build hindsight-client (self-driving-agents dep)
run: npm run build --workspace=hindsight-clients/typescript
- name: Run tests
run: npm test --workspace=hindsight-tools/self-driving-agents
- name: Build
run: npm run build --workspace=hindsight-tools/self-driving-agents
test-cloudflare-oauth-proxy-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-cloudflare-oauth-proxy == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -605,6 +627,7 @@ jobs:
build-chat-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-chat == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -635,6 +658,7 @@ jobs:
test-paperclip-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-paperclip == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -665,6 +689,7 @@ jobs:
test-pipecat-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-pipecat == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -702,6 +727,7 @@ jobs:
build-control-plane:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.control-plane == 'true' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
@@ -762,6 +788,7 @@ jobs:
build-docs:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.docs == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -789,7 +816,8 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
(github.event_name == 'workflow_dispatch' ||
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.cli == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -917,6 +945,7 @@ jobs:
lint-helm-chart:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.helm == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -939,7 +968,8 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
(github.event_name == 'workflow_dispatch' ||
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.docker == 'true' ||
needs.detect-changes.outputs.control-plane == 'true' ||
@@ -1032,7 +1062,8 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
(github.event_name == 'workflow_dispatch' ||
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
@@ -1102,111 +1133,7 @@ jobs:
- name: Run tests
working-directory: ./hindsight-api-slim
run: uv run pytest tests -v -m "not hs_llm_mat"
test-api-llm-acceptance:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- provider: vertexai
model: google/gemini-2.5-flash-lite
- provider: gemini
model: gemini-2.5-flash-lite
api_key_secret: GEMINI_API_KEY
- provider: openai
model: gpt-4.1-nano
api_key_secret: OPENAI_API_KEY
- provider: groq
model: openai/gpt-oss-20b
api_key_secret: GROQ_API_KEY
- provider: bedrock
model: us.amazon.nova-2-lite-v1:0
- provider: litellmrouter
model: gpt-4.1-nano
# Single-deployment chain over OpenAI — verifies the Router-backed
# call path works end-to-end. Built from secrets in the step below.
name: LLM acceptance (${{ matrix.provider }}/${{ matrix.model }})
env:
HINDSIGHT_API_LLM_PROVIDER: ${{ matrix.provider }}
HINDSIGHT_API_LLM_MODEL: ${{ matrix.model }}
HINDSIGHT_API_LLM_API_KEY: ${{ matrix.api_key_secret && secrets[matrix.api_key_secret] || '' }}
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION_NAME: ${{ secrets.AWS_REGION_NAME }}
HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Setup GCP credentials
run: |
printf '%s' '${{ secrets.GCP_VERTEXAI_CREDENTIALS }}' > /tmp/gcp-credentials.json
PROJECT_ID=$(jq -r '.project_id' /tmp/gcp-credentials.json)
echo "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=$PROJECT_ID" >> $GITHUB_ENV
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Install dependencies
working-directory: ./hindsight-api-slim
run: uv sync --frozen --all-extras --index-strategy unsafe-best-match
- name: Cache HuggingFace models
uses: actions/cache@v5
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api-slim/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api-slim
run: |
uv run python -c "
from sentence_transformers import SentenceTransformer, CrossEncoder
SentenceTransformer('BAAI/bge-small-en-v1.5')
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
"
- name: Build litellmrouter config
if: matrix.provider == 'litellmrouter'
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ROUTER_MODEL: ${{ matrix.model }}
run: |
cfg=$(jq -nc \
--arg model "openai/$ROUTER_MODEL" \
--arg key "$OPENAI_API_KEY" \
'{model_list: [{model_name: "default", litellm_params: {model: $model, api_key: $key}}]}')
echo "::add-mask::$cfg"
echo "HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG=$cfg" >> "$GITHUB_ENV"
- name: Run LLM acceptance tests
working-directory: ./hindsight-api-slim
run: uv run pytest tests -v -m "hs_llm_mat" --timeout 600
run: uv run pytest tests -v
test-api-oracle:
needs: [detect-changes]
@@ -1215,7 +1142,8 @@ jobs:
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
contains(github.event.pull_request.labels.*.name, 'oracle-tests') &&
(github.event_name == 'workflow_dispatch' ||
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
@@ -1333,7 +1261,8 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
(github.event_name == 'workflow_dispatch' ||
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-python == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -1444,7 +1373,8 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
(github.event_name == 'workflow_dispatch' ||
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -1573,7 +1503,8 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
(github.event_name == 'workflow_dispatch' ||
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-python == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -1732,7 +1663,8 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
(github.event_name == 'workflow_dispatch' ||
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -1891,7 +1823,8 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
(github.event_name == 'workflow_dispatch' ||
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2012,6 +1945,7 @@ jobs:
build-rust-cli-arm64:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.cli == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2044,7 +1978,8 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
(github.event_name == 'workflow_dispatch' ||
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-rust == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2159,7 +2094,8 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
(github.event_name == 'workflow_dispatch' ||
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-go == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2272,7 +2208,8 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
(github.event_name == 'workflow_dispatch' ||
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.integrations-openclaw == 'true' ||
needs.detect-changes.outputs.embed == 'true' ||
@@ -2404,7 +2341,8 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
(github.event_name == 'workflow_dispatch' ||
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.integration-tests == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2509,6 +2447,7 @@ jobs:
test-ag2-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-ag2 == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2545,6 +2484,7 @@ jobs:
test-smolagents-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-smolagents == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2578,43 +2518,10 @@ jobs:
working-directory: ./hindsight-integrations/smolagents
run: uv run pytest tests -v
test-dify-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-dify == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || '' }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
# Dify plugin runtime targets Python 3.12 (see manifest.yaml)
python-version: '3.12'
- name: Install dependencies
working-directory: ./hindsight-integrations/dify
run: uv pip install --system -e . pytest pytest-mock
- name: Run tests
working-directory: ./hindsight-integrations/dify
run: pytest tests -v
test-crewai-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-crewai == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2651,6 +2558,7 @@ jobs:
test-litellm-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-litellm == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2687,6 +2595,7 @@ jobs:
test-pydantic-ai-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-pydantic-ai == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2723,6 +2632,7 @@ jobs:
test-llamaindex-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-llamaindex == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2759,6 +2669,7 @@ jobs:
test-openai-agents-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-openai-agents == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2795,6 +2706,7 @@ jobs:
test-agentcore-integration:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.integrations-agentcore == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2832,7 +2744,8 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
(github.event_name == 'workflow_dispatch' ||
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.ci == 'true')
runs-on: ubuntu-latest
@@ -2900,7 +2813,8 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
(github.event_name == 'workflow_dispatch' ||
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.embed == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -2967,7 +2881,8 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
(github.event_name == 'workflow_dispatch' ||
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.embed == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -3144,7 +3059,8 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
(github.event_name == 'workflow_dispatch' ||
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.hindsight-all == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -3228,7 +3144,8 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
(github.event_name == 'workflow_dispatch' ||
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.clients-ts == 'true' ||
needs.detect-changes.outputs.clients-python == 'true' ||
@@ -3380,7 +3297,8 @@ jobs:
needs: [detect-changes]
if: >-
needs.detect-changes.outputs.has_secrets == 'true' &&
(github.event_name == 'workflow_dispatch' ||
((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.dev == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -3462,9 +3380,8 @@ jobs:
done
verify-generated-files:
if: github.event_name != 'pull_request_review'
runs-on: ubuntu-latest
env:
UV_FROZEN: "1"
steps:
- uses: actions/checkout@v6
with:
@@ -3545,6 +3462,7 @@ jobs:
check-openapi-compatibility:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.ci == 'true')
@@ -3596,6 +3514,7 @@ jobs:
check-cli-coverage:
needs: [detect-changes]
if: >-
github.event_name != 'pull_request_review' &&
(github.event_name == 'workflow_dispatch' ||
needs.detect-changes.outputs.core == 'true' ||
needs.detect-changes.outputs.cli == 'true' ||
@@ -3666,7 +3585,6 @@ jobs:
- test-integration
- test-ag2-integration
- test-smolagents-integration
- test-dify-integration
- test-crewai-integration
- test-litellm-integration
- test-pydantic-ai-integration
@@ -3677,6 +3595,7 @@ jobs:
- test-embed-windows
- test-hindsight-all
- test-hindsight-agent-sdk
- test-self-driving-agents
- test-doc-examples
- test-upgrade
- verify-generated-files
@@ -3765,4 +3684,4 @@ jobs:
issue_number: prNumber,
body,
});
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ It eliminates the shortcomings of alternative techniques such as RAG and knowled
Hindsight is the most accurate agent memory system ever tested according to benchmark performance. It has achieved state-of-the-art performance on the LongMemEval benchmark, widely used to assess memory system performance across a variety of conversational AI scenarios. The current reported performance of Hindsight and other agent memory solutions as of January 2026 is shown here:
![Overview](./hindsight-docs/static/img/hindsight-benchmarks.png)
![Overview](./hindsight-docs/static/img/hindsight-bench.jpg)
The benchmark performance data for Hindsight has been independently reproduced by research collaborators at the Virginia Tech [Sanghani Center for Artificial Intelligence and Data Analytics](https://sanghani.cs.vt.edu/) and The Washington Post. Other scores are self-reported by software vendors.
Generated
+1 -33
View File
@@ -42,16 +42,6 @@
},
"workspace": {
"members": {
"hindsight-all-npm": {
"packageJson": {
"dependencies": [
"npm:@types/node@22",
"npm:tsup@^8.5.1",
"npm:typescript@^5.7.0",
"npm:vitest@^4.1.2"
]
}
},
"hindsight-clients/typescript": {
"packageJson": {
"dependencies": [
@@ -68,7 +58,6 @@
"hindsight-control-plane": {
"packageJson": {
"dependencies": [
"npm:@chenglou/pretext@^0.0.3",
"npm:@eslint/eslintrc@^3.3.3",
"npm:@eslint/js@^9.39.2",
"npm:@radix-ui/react-alert-dialog@^1.1.15",
@@ -102,12 +91,11 @@
"npm:eslint@^9.39.1",
"npm:[email protected]",
"npm:next-themes@~0.4.6",
"npm:next@^16.1.7",
"npm:next@^16.1.6",
"npm:postcss@^8.5.6",
"npm:prettier@^3.7.4",
"npm:react-chrono@^2.9.1",
"npm:react-dom@^19.2.0",
"npm:react-is@^19.2.4",
"npm:react-markdown@^10.1.0",
"npm:react18-json-view@~0.2.9",
"npm:react@^19.2.0",
@@ -145,26 +133,6 @@
"npm:typescript@~5.6.2"
]
}
},
"hindsight-tools/hindsight-agent-sdk": {
"packageJson": {
"dependencies": [
"npm:@vectorize-io/hindsight-client@~0.5.6",
"npm:typescript@^5.4.0",
"npm:vitest@^4.1.2"
]
}
},
"hindsight-tools/self-driving-agents": {
"packageJson": {
"dependencies": [
"npm:@clack/prompts@^1.2.0",
"npm:@vectorize-io/hindsight-client@~0.5.6",
"npm:picocolors@^1.1.0",
"npm:typescript@^5.4.0",
"npm:vitest@^4.1.2"
]
}
}
}
}
@@ -1,90 +0,0 @@
name: hindsight
# Docker Compose file for Hindsight with AlloyDB Omni and ScaNN
# Uses Google's free AlloyDB Omni container image: https://hub.docker.com/r/google/alloydbomni
#
# Usage:
# docker compose -f docker/docker-compose/alloydb/docker-compose.yaml up -d
#
# Make sure to set the required environment variables before running:
# - HINDSIGHT_DB_PASSWORD: password for the AlloyDB Omni/PostgreSQL user
# - Configure LLM provider variables as needed (see the hindsight service below)
#
# Optional environment variables with defaults:
# - HINDSIGHT_VERSION: Hindsight application version (default: latest)
# - HINDSIGHT_DB_VERSION: AlloyDB Omni image tag (default: 17)
# - HINDSIGHT_DB_USER: database user (default: hindsight_user)
# - HINDSIGHT_DB_NAME: database name (default: hindsight_db)
services:
db:
image: google/alloydbomni:${HINDSIGHT_DB_VERSION:-17}
container_name: hindsight-db-alloydb
restart: always
ports:
- "5438:5432"
environment:
POSTGRES_USER: ${HINDSIGHT_DB_USER:-hindsight_user}
POSTGRES_PASSWORD: ${HINDSIGHT_DB_PASSWORD:-hindsight_password}
POSTGRES_DB: ${HINDSIGHT_DB_NAME:-hindsight_db}
volumes:
- alloydb_data:/var/lib/postgresql/data
networks:
- hindsight-net
alloydb-init:
image: google/alloydbomni:${HINDSIGHT_DB_VERSION:-17}
depends_on:
- db
environment:
- PGPASSWORD=${HINDSIGHT_DB_PASSWORD:-hindsight_password}
command:
- bash
- -c
- |
echo 'Waiting for AlloyDB Omni to be ready...'
until pg_isready -h hindsight-db-alloydb -p 5432 -U ${HINDSIGHT_DB_USER:-hindsight_user}; do
echo 'AlloyDB Omni is unavailable - sleeping'
sleep 2
done
echo 'AlloyDB Omni is ready - creating ${HINDSIGHT_DB_NAME:-hindsight_db} database'
psql -h hindsight-db-alloydb -p 5432 -U ${HINDSIGHT_DB_USER:-hindsight_user} -c 'CREATE DATABASE ${HINDSIGHT_DB_NAME:-hindsight_db};' 2>/dev/null || echo 'Database already exists'
echo 'Creating vector and alloydb_scann extensions'
psql -h hindsight-db-alloydb -p 5432 -U ${HINDSIGHT_DB_USER:-hindsight_user} -d ${HINDSIGHT_DB_NAME:-hindsight_db} -c 'CREATE EXTENSION IF NOT EXISTS vector;'
psql -h hindsight-db-alloydb -p 5432 -U ${HINDSIGHT_DB_USER:-hindsight_user} -d ${HINDSIGHT_DB_NAME:-hindsight_db} -c 'CREATE EXTENSION IF NOT EXISTS alloydb_scann CASCADE;'
echo 'Database and extensions created successfully'
restart: "no"
networks:
- hindsight-net
hindsight:
image: ghcr.io/vectorize-io/hindsight:${HINDSIGHT_VERSION:-latest}
container_name: hindsight-app
ports:
- "8888:8888"
- "9999:9999"
environment:
# LLM Configuration
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
# Database Configuration
HINDSIGHT_API_DATABASE_URL: postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:-hindsight_password}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
# Vector and Text Search Extensions
HINDSIGHT_API_VECTOR_EXTENSION: scann
HINDSIGHT_API_TEXT_SEARCH_EXTENSION: native
depends_on:
db:
condition: service_started
alloydb-init:
condition: service_completed_successfully
networks:
- hindsight-net
networks:
hindsight-net:
driver: bridge
volumes:
alloydb_data:
@@ -1,34 +0,0 @@
# Example: custom Hindsight image with non-default local models baked in.
#
# Use this pattern in production when you run a non-default embedder or
# reranker. Baking models into the image removes the runtime dependency on
# HuggingFace and lets the container registry handle caching per node, so
# you don't need a model-cache PVC.
#
# Built on top of the slim image so only the deps and models you actually
# use end up in the final image.
FROM ghcr.io/vectorize-io/hindsight:latest-slim
# Install the local-ml deps required to load sentence-transformers /
# cross-encoder models at runtime. Pinned ranges mirror hindsight-api-slim's
# `local-ml` extra in hindsight-api-slim/pyproject.toml. Use `uv pip
# install` against the image's venv explicitly: the slim image's venv was
# created by `uv sync` and does not ship its own `pip`, so a bare
# `pip install` would fall back to user site-packages and not be visible
# to the runtime python.
RUN uv pip install --python /app/api/.venv/bin/python --no-cache \
'sentence-transformers>=3.3.0' \
'transformers>=4.53.0' \
'torch>=2.6.0'
# Pre-download the models you want to use. Replace these with your own.
# The defaults bundled in the full image are BAAI/bge-small-en-v1.5 and
# cross-encoder/ms-marco-MiniLM-L-6-v2; here we pick multilingual variants
# as a concrete non-default example.
ARG EMBEDDER=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
ARG RERANKER=cross-encoder/mmarco-mMiniLMv2-L12-H384-v1
ENV HF_HUB_DOWNLOAD_TIMEOUT=600
RUN python -c "\
from sentence_transformers import SentenceTransformer, CrossEncoder; \
SentenceTransformer('${EMBEDDER}'); \
CrossEncoder('${RERANKER}')"
@@ -1,81 +0,0 @@
# Hindsight with Custom Local Models
Example Docker Compose setup that builds a Hindsight image with **non-default
local embedder and reranker models baked in at build time**.
This is the recommended pattern for production when you use a non-default
local model: the container registry caches model layers per node, pod
startup is deterministic, and you don't need a model-cache PVC (or any
runtime dependency on HuggingFace).
## When to use this
- You override `HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL` or
`HINDSIGHT_API_RERANKER_LOCAL_MODEL` to a non-default model.
- You want pod startup to be deterministic and offline-capable.
- You'd otherwise reach for a Helm `modelCache` PVC just to avoid
re-downloading models.
If you're using the **default** local models, the published full image
(`ghcr.io/vectorize-io/hindsight:latest`) already bakes them in — you don't
need this example.
If you're using **external** providers (TEI, OpenAI, Cohere, ...) for
embeddings and reranking, use the slim image directly — no models are
needed in the image.
## Quick start
```bash
export OPENAI_API_KEY=sk-xxx
docker compose -f docker/docker-compose/custom-models/docker-compose.yaml up --build
```
- API: http://localhost:8888
- Control Plane: http://localhost:9999
## Using your own models
Override the build args to bake different models:
```bash
docker compose -f docker/docker-compose/custom-models/docker-compose.yaml build \
--build-arg EMBEDDER=your-org/your-embedder \
--build-arg RERANKER=your-org/your-reranker
```
Then update the matching `HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL` and
`HINDSIGHT_API_RERANKER_LOCAL_MODEL` values in `docker-compose.yaml` so the
runtime points at the same model IDs.
## Verifying the models are baked in
`docker-compose.yaml` sets `HF_HUB_OFFLINE=1` and `TRANSFORMERS_OFFLINE=1`
so that any attempt to download a model at runtime fails loudly instead of
silently re-downloading. If the container starts and serves recall queries
with these set, the models are correctly baked in.
You can also inspect the image directly:
```bash
docker run --rm --entrypoint sh hindsight-custom-models-hindsight \
-c 'ls ~/.cache/huggingface/hub/'
```
## Why not a model-cache PVC?
The Helm chart exposes an optional `api.persistence.modelCache` PVC for
caching downloaded models across pod restarts. Compared to baking models
into the image:
- A PVC adds storage cost — one PVC per worker replica with
`volumeClaimTemplates`.
- `ReadWriteOnce` (the default) pins pods to a node.
- The PVC needs lifecycle management on `helm uninstall` / `helm upgrade`
— without `helm.sh/resource-policy: keep` it is deleted on uninstall;
with it, storage keeps billing forever until manually cleaned up.
- Pod startup still depends on HuggingFace being reachable on first run.
Image layers, by contrast, are pulled once per node and cached for free by
the container runtime, with no orphaned-storage cleanup story.
@@ -1,44 +0,0 @@
name: hindsight-custom-models
# Example: run a custom Hindsight image with non-default local models baked
# in at build time, so pod startup does not depend on HuggingFace at runtime.
#
# Quick start:
# export OPENAI_API_KEY=sk-xxx
# docker compose -f docker/docker-compose/custom-models/docker-compose.yaml up --build
#
# Required environment variables:
# - OPENAI_API_KEY (or configure another LLM provider via HINDSIGHT_API_LLM_*)
services:
hindsight:
build:
context: .
dockerfile: Dockerfile
# Override at build time to bake different models:
# docker compose build --build-arg EMBEDDER=your-org/your-embedder
args:
EMBEDDER: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
RERANKER: cross-encoder/mmarco-mMiniLMv2-L12-H384-v1
container_name: hindsight-custom-models
ports:
- "8888:8888"
- "9999:9999"
environment:
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
# Point Hindsight at the models baked into the image above.
HINDSIGHT_API_EMBEDDINGS_PROVIDER: local
HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
HINDSIGHT_API_RERANKER_PROVIDER: local
HINDSIGHT_API_RERANKER_LOCAL_MODEL: cross-encoder/mmarco-mMiniLMv2-L12-H384-v1
# Fail fast if a model is missing from the image instead of silently
# falling back to a HuggingFace download at runtime.
HF_HUB_OFFLINE: "1"
TRANSFORMERS_OFFLINE: "1"
volumes:
- pg_data:/home/hindsight/.pg0
volumes:
pg_data:
@@ -51,8 +51,6 @@ services:
# Control Plane config
HINDSIGHT_CP_DATAPLANE_API_URL: http://localhost:8888
# Optional: Require a shared access key for Control Plane UI access
# HINDSIGHT_CP_ACCESS_KEY: your-secret-key
volumes:
# Persist embedded pg0 database
- hindsight_data:/app/data
-8
View File
@@ -172,10 +172,6 @@ USER hindsight
# "Permission denied" error when mounting a fresh root-owned volume.
RUN mkdir -p /home/hindsight/.pg0
# Make /home/hindsight traversable when running with --user UID:GID overrides
# (default 0700 blocks traversal by non-owner UIDs needed for bind-mount ownership matching)
RUN chmod 755 /home/hindsight
ENV PATH="/app/api/.venv/bin:${PATH}"
# Pre-download tiktoken encoding (ALWAYS - required for token counting even in air-gapped envs)
@@ -332,10 +328,6 @@ USER hindsight
# "Permission denied" error when mounting a fresh root-owned volume.
RUN mkdir -p /home/hindsight/.pg0
# Make /home/hindsight traversable when running with --user UID:GID overrides
# (default 0700 blocks traversal by non-owner UIDs needed for bind-mount ownership matching)
RUN chmod 755 /home/hindsight
ENV PATH="/app/api/.venv/bin:${PATH}"
# Pre-download tiktoken encoding (ALWAYS - required for token counting even in air-gapped envs)
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.6.2
appVersion: "0.6.2"
version: 0.5.6
appVersion: "0.5.6"
keywords:
- ai
- memory
+1 -10
View File
@@ -70,12 +70,6 @@ api:
# Persistent volume for local model cache (reranker, embeddings)
# Models are downloaded to /home/hindsight/.cache on first use.
# Without persistence, models are re-downloaded on every pod restart.
#
# For production, prefer baking models into a custom image instead of
# enabling this PVC: image layers are pulled once per node and cached
# for free, while a PVC adds storage cost, pins pods to a node
# (ReadWriteOnce), and needs lifecycle management on uninstall/upgrade.
# See docs: developer/installation#bundling-custom-models-in-a-custom-image
persistence:
modelCache:
enabled: false
@@ -174,10 +168,7 @@ worker:
# affinity: {}
# Persistent volume for local model cache (reranker, embeddings)
# Uses volumeClaimTemplates since worker is a StatefulSet — one PVC per
# replica. For production, prefer baking models into a custom image; see
# api.persistence.modelCache above and docs:
# developer/installation#bundling-custom-models-in-a-custom-image
# Uses volumeClaimTemplates since worker is a StatefulSet.
persistence:
modelCache:
enabled: false
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-all",
"version": "0.6.2",
"version": "0.5.6",
"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.6.2"
version = "0.5.6"
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.6.2",
"hindsight-api-slim>=0.4.17",
"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.6.2"
version = "0.5.6"
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.6.2",
"hindsight-api-slim[all]>=0.4.17",
"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.6.2",
"hindsight-api-slim[local-llm]>=0.4.17",
]
test = [
"pytest>=7.0.0",
+1 -1
View File
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.6.2"
__version__ = "0.5.6"
@@ -1,170 +0,0 @@
"""Shared PostgreSQL vector-extension dispatch helpers."""
from __future__ import annotations
import logging
from sqlalchemy import text
from sqlalchemy.engine import Connection
logger = logging.getLogger(__name__)
# Extensions a user can set via HINDSIGHT_API_VECTOR_EXTENSION.
CONFIGURABLE_EXTENSIONS = ("pgvector", "pgvectorscale", "vchord", "scann")
# Extensions detect_vector_extension() can return. pg_diskann is a runtime-only
# resolution from a configured "pgvectorscale" backend on Azure (uses a different
# WITH clause), never a value the user sets directly.
RESOLVED_EXTENSIONS = (*CONFIGURABLE_EXTENSIONS, "pg_diskann")
# Backwards-compatible alias for older imports.
VALID_EXTENSIONS = CONFIGURABLE_EXTENSIONS
SCANN_MIN_ROWS_FOR_AUTO_INDEX = 10_000
_EXTENSION_NAMES = {
"pgvector": "vector",
"pgvectorscale": "vectorscale",
"vchord": "vchord",
"scann": "alloydb_scann",
}
_INDEX_USING_CLAUSES = {
"pgvector": "USING hnsw (embedding vector_cosine_ops)",
"pgvectorscale": "USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)",
"pg_diskann": "USING diskann (embedding vector_cosine_ops) WITH (max_neighbors = 50)",
"vchord": "USING vchordrq (embedding vector_l2_ops)",
"scann": "USING scann (embedding cosine) WITH (mode = 'AUTO')",
}
_INDEX_TYPE_KEYWORDS = {
"pgvector": "hnsw",
"pgvectorscale": "diskann",
"pg_diskann": "diskann",
"vchord": "vchordrq",
"scann": "scann",
}
_EXTENSION_INSTALL_SQL = {
"pgvector": ("CREATE EXTENSION IF NOT EXISTS vector",),
"pgvectorscale": (
"CREATE EXTENSION IF NOT EXISTS vector",
"CREATE EXTENSION IF NOT EXISTS vectorscale CASCADE",
),
"vchord": ("CREATE EXTENSION IF NOT EXISTS vchord CASCADE",),
"scann": (
"CREATE EXTENSION IF NOT EXISTS vector",
"CREATE EXTENSION IF NOT EXISTS alloydb_scann CASCADE",
),
}
_INSTALL_HINTS = {
"pgvector": "CREATE EXTENSION vector;",
"pgvectorscale": "CREATE EXTENSION vector; then CREATE EXTENSION vectorscale CASCADE; (or pg_diskann on Azure)",
"vchord": "CREATE EXTENSION vchord CASCADE;",
"scann": "CREATE EXTENSION vector; then CREATE EXTENSION alloydb_scann CASCADE;",
}
def validate_extension(name: str) -> str:
"""Return a normalized configurable vector extension name or raise.
Used at the user-facing config boundary; pg_diskann is rejected here because
it is a detection-time alias, never a value the user sets directly.
"""
ext = name.lower()
if ext not in CONFIGURABLE_EXTENSIONS:
valid = ", ".join(CONFIGURABLE_EXTENSIONS)
raise ValueError(f"Invalid vector_extension: {name}. Must be one of: {valid}")
return ext
def _normalize_resolved(name: str) -> str:
"""Normalize either a user-configurable or detect-time extension name."""
ext = name.lower()
if ext not in RESOLVED_EXTENSIONS:
valid = ", ".join(RESOLVED_EXTENSIONS)
raise ValueError(f"Unknown vector extension: {name}. Must be one of: {valid}")
return ext
def pg_extension_name(ext: str) -> str:
"""Return the PostgreSQL extension name for a configured vector backend."""
return _EXTENSION_NAMES[validate_extension(ext)]
def index_using_clause(ext: str) -> str:
"""Return the CREATE INDEX USING clause for the vector backend."""
return _INDEX_USING_CLAUSES[_normalize_resolved(ext)]
def index_type_keyword(ext: str) -> str:
"""Return the keyword that identifies this index type in pg_indexes.indexdef."""
return _INDEX_TYPE_KEYWORDS[_normalize_resolved(ext)]
def minimum_rows_for_index(ext: str) -> int:
"""Return the minimum populated embedding rows before creating this index type."""
return SCANN_MIN_ROWS_FOR_AUTO_INDEX if _normalize_resolved(ext) == "scann" else 0
def should_defer_index_creation(ext: str, row_count: int) -> bool:
"""Return True when index creation should wait for more embeddings."""
minimum_rows = minimum_rows_for_index(ext)
return minimum_rows > 0 and row_count < minimum_rows
def uses_per_bank_vector_indexes(ext: str) -> bool:
"""Return whether the backend should create per-bank partial vector indexes."""
return _normalize_resolved(ext) != "scann"
def bootstrap_extension(conn: Connection, ext: str) -> None:
"""Install the configured vector extension and any prerequisites if possible."""
normalized = validate_extension(ext)
for statement in _EXTENSION_INSTALL_SQL[normalized]:
conn.execute(text(statement))
def detect_vector_extension(conn: Connection, vector_extension: str = "pgvector") -> str:
"""Validate the configured vector extension exists and return the index backend."""
configured_ext = validate_extension(vector_extension)
if configured_ext == "pgvectorscale":
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
if not pgvector_check:
raise RuntimeError(
"DiskANN (pgvectorscale/pg_diskann) requires pgvector to be installed. "
f"Install it with: {_INSTALL_HINTS['pgvectorscale']}"
)
vectorscale_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")).scalar()
pg_diskann_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_diskann'")).scalar()
if vectorscale_check:
logger.debug("Using vector extension: pgvectorscale (DiskANN)")
return "pgvectorscale"
if pg_diskann_check:
logger.debug("Using vector extension: pg_diskann (Azure DiskANN)")
return "pg_diskann"
raise RuntimeError(
"Configured vector extension 'pgvectorscale' not found. Install either:\n"
" - pgvectorscale (open source): CREATE EXTENSION vectorscale CASCADE;\n"
" - pg_diskann (Azure): CREATE EXTENSION pg_diskann CASCADE;"
)
extension_name = pg_extension_name(configured_ext)
extension_check = conn.execute(
text("SELECT 1 FROM pg_extension WHERE extname = :extension_name"),
{"extension_name": extension_name},
).scalar()
if not extension_check:
raise RuntimeError(
f"Configured vector extension '{configured_ext}' not found. "
f"Install it with: {_INSTALL_HINTS[configured_ext]}"
)
logger.debug("Using configured vector extension: %s", configured_ext)
return configured_ext
@@ -26,67 +26,52 @@ depends_on: str | Sequence[str] | None = None
def _detect_vector_extension() -> str:
"""
Detect or validate vector extension for this immutable migration revision.
Detect or validate vector extension: 'pgvector', 'vchord', or 'pgvectorscale'.
Respects HINDSIGHT_API_VECTOR_EXTENSION env var if set.
"""
conn = op.get_bind()
vector_extension = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
# Validate configured extension is installed
if vector_extension == "pgvectorscale":
# pgvectorscale/DiskANN requires pgvector
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
if not pgvector_check:
raise RuntimeError(
"DiskANN requires pgvector. Install with: CREATE EXTENSION vector; then vectorscale or pg_diskann CASCADE;"
)
# Check for either vectorscale (open source) or pg_diskann (Azure)
vectorscale_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")).scalar()
pg_diskann_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_diskann'")).scalar()
if vectorscale_check:
return "pgvectorscale"
if pg_diskann_check:
elif pg_diskann_check:
return "pg_diskann"
raise RuntimeError(
"Configured vector extension 'pgvectorscale' not found. Install either:\n"
" - pgvectorscale: CREATE EXTENSION vectorscale CASCADE;\n"
" - pg_diskann (Azure): CREATE EXTENSION pg_diskann CASCADE;"
)
if vector_extension == "vchord":
else:
raise RuntimeError(
"Configured vector extension 'pgvectorscale' not found. Install either:\n"
" - pgvectorscale: CREATE EXTENSION vectorscale CASCADE;\n"
" - pg_diskann (Azure): CREATE EXTENSION pg_diskann CASCADE;"
)
elif vector_extension == "vchord":
vchord_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vchord'")).scalar()
if not vchord_check:
raise RuntimeError(
"Configured vector extension 'vchord' not found. Install it with: CREATE EXTENSION vchord CASCADE;"
)
return "vchord"
if vector_extension == "scann":
scann_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'alloydb_scann'")).scalar()
if not scann_check:
raise RuntimeError(
"Configured vector extension 'scann' not found. Install it with: CREATE EXTENSION alloydb_scann CASCADE;"
)
return "scann"
if vector_extension == "pgvector":
elif vector_extension == "pgvector":
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
if not pgvector_check:
raise RuntimeError(
"Configured vector extension 'pgvector' not found. Install it with: CREATE EXTENSION vector;"
)
return "pgvector"
raise ValueError(
"Invalid HINDSIGHT_API_VECTOR_EXTENSION: "
f"{vector_extension}. Must be 'pgvector', 'vchord', 'pgvectorscale', or 'scann'"
)
def _vector_index_using_clause(ext: str) -> str:
if ext == "pgvectorscale":
return "USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)"
if ext == "pg_diskann":
return "USING diskann (embedding vector_cosine_ops) WITH (max_neighbors = 50)"
if ext == "vchord":
return "USING vchordrq (embedding vector_l2_ops)"
if ext == "scann":
return "USING scann (embedding cosine) WITH (mode = 'AUTO')"
return "USING hnsw (embedding vector_cosine_ops)"
else:
raise ValueError(
f"Invalid HINDSIGHT_API_VECTOR_EXTENSION: {vector_extension}. Must be 'pgvector', 'vchord', or 'pgvectorscale'"
)
def _detect_text_search_extension() -> str:
@@ -328,11 +313,36 @@ def _pg_upgrade() -> None:
)
# Create vector index - conditional based on available extension
vector_ext = _detect_vector_extension()
if vector_ext != "scann":
op.execute(f"""
if vector_ext == "pgvectorscale":
# Use DiskANN index for pgvectorscale (disk-based, scalable)
op.execute("""
CREATE INDEX idx_memory_units_embedding ON memory_units
{_vector_index_using_clause(vector_ext)}
USING diskann (embedding vector_cosine_ops)
WITH (num_neighbors = 50)
""")
elif vector_ext == "pg_diskann":
# Use DiskANN index for pg_diskann (Azure)
op.execute("""
CREATE INDEX idx_memory_units_embedding ON memory_units
USING diskann (embedding vector_cosine_ops)
WITH (max_neighbors = 50)
""")
elif vector_ext == "vchord":
# Use vchordrq index for vchord (supports high-dimensional embeddings)
op.execute("""
CREATE INDEX idx_memory_units_embedding ON memory_units
USING vchordrq (embedding vector_l2_ops)
""")
else: # pgvector
# Use HNSW index for pgvector
op.create_index(
"idx_memory_units_embedding",
"memory_units",
["embedding"],
postgresql_using="hnsw",
postgresql_ops={"embedding": "vector_cosine_ops"},
)
# Create full-text search index on search_vector
# Index type depends on text search backend
@@ -1,117 +0,0 @@
"""Repair mental_models.subtype on databases stuck at m3rg3h3ad5f6
Three production deployments reported `column "subtype" of relation
"mental_models" does not exist` on `create_mental_model` even after their
container reported `Database migrations completed successfully` and
`alembic_version` advanced to `m3rg3h3ad5f6` (see issue #1553, #1553#1
confirmations from @4Lienau and @khanhduyvt0101).
Both `h3c4d5e6f7g8_mental_models_v4` and `d5y6z7a8b9c0_backfill_mental_models_subtype`
were meant to ensure `subtype` exists, but on databases that came through the
`reflections -> mental_models` rename chain *and* whose alembic_version
advanced past `d5y6z7a8b9c0` along an alternate path during the divergent-heads
reorganization, neither column-add actually fired. The result is a head-tagged
database with a v3-shaped `mental_models` table missing six columns:
``subtype``, ``description``, ``entity_id``, ``observations``, ``links``,
``last_updated``.
This migration sits at the current head (`m3rg3h3ad5f6`) so every affected
deployment will pick it up on next container start. It mirrors the column-add
block from `d5y6z7a8b9c0_backfill_mental_models_subtype` using
``ADD COLUMN IF NOT EXISTS`` so it is a no-op on databases where the columns
are already present.
Revision ID: 86f7a033d372
Revises: m3rg3h3ad5f6
Create Date: 2026-05-14
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "86f7a033d372"
down_revision: str | Sequence[str] | None = "m3rg3h3ad5f6"
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:
"""Idempotently ensure mental_models has the v4 column set.
Safe to re-apply on databases that already received the columns via
`h3c4d5e6f7g8_mental_models_v4` or `d5y6z7a8b9c0_backfill_mental_models_subtype` —
every column-add uses ``IF NOT EXISTS`` and the constraint is recreated
from scratch with the canonical v4 allowlist.
"""
schema = _pg_schema_prefix()
bare_schema = schema.strip(".").strip('"') if schema else ""
schema_clause = f"AND table_schema = '{bare_schema}'" if bare_schema else ""
# Wrapped in a DO block so the existence check skips databases that
# predate the reflections -> mental_models rename chain (no table to
# repair). On those, every ALTER below would error.
op.execute(
f"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'mental_models'
{schema_clause}
) THEN
-- Add the six v4 columns idempotently.
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS subtype VARCHAR(32) NOT NULL DEFAULT 'structural';
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT '';
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS entity_id UUID;
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS observations JSONB DEFAULT '{{"observations": []}}'::jsonb;
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS links VARCHAR[];
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS last_updated TIMESTAMP WITH TIME ZONE;
-- Recreate the CHECK constraint with the canonical v4 allowlist.
-- Existing rows with subtype = 'directive' (possible on databases
-- that ran the o0j1k2l3m4n5 directive-only path) are rewritten to
-- 'structural' first so the constraint add succeeds.
UPDATE {schema}mental_models SET subtype = 'structural' WHERE subtype = 'directive';
ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype;
ALTER TABLE {schema}mental_models
ADD CONSTRAINT ck_mental_models_subtype
CHECK (subtype IN ('structural', 'emergent', 'pinned', 'learned'));
CREATE INDEX IF NOT EXISTS idx_mental_models_subtype
ON {schema}mental_models(bank_id, subtype);
END IF;
END$$;
"""
)
def _pg_downgrade() -> None:
"""No-op: dropping these columns would corrupt v4 application code."""
pass
def upgrade() -> None:
# PG-only: Oracle's baseline (o1a2b3c4d5e6) creates mental_models with its
# own subtype shape (chk_mm_subtype IN ('directive', 'pinned')) and a
# different table topology, so this PG-shaped repair does not apply.
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -11,8 +11,7 @@ was configured.
This migration detects the mismatch and recreates the affected indexes with
the correct type. Skipped entirely when the configured extension is pgvector
(the default) or scann. ScaNN uses global vector indexes because empty or tiny
per-bank indexes cannot be built safely on AlloyDB.
(the default), since those indexes are already correct.
"""
import os
@@ -40,47 +39,39 @@ def _get_schema_prefix() -> str:
return f'"{schema}".' if schema else ""
def _validate_extension(name: str) -> str:
ext = name.lower()
if ext not in {"pgvector", "pgvectorscale", "vchord", "scann"}:
raise ValueError(
f"Invalid HINDSIGHT_API_VECTOR_EXTENSION: {ext}. Must be 'pgvector', 'vchord', 'pgvectorscale', or 'scann'"
)
return ext
def _index_type_keyword(ext: str) -> str:
def _target_index_type() -> str | None:
"""Return the target index type, or None if pgvector (no fix needed)."""
ext = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
if ext == "pgvectorscale":
return "diskann"
if ext == "vchord":
elif ext == "vchord":
return "vchordrq"
if ext == "scann":
return "scann"
return "hnsw"
return None
def _vector_index_using_clause(ext: str) -> str:
def _vector_index_using_clause() -> str:
"""Return the USING clause based on the configured vector extension."""
ext = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
if ext == "pgvectorscale":
return "USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)"
if ext == "vchord":
elif ext == "vchord":
return "USING vchordrq (embedding vector_l2_ops)"
if ext == "scann":
return "USING scann (embedding cosine) WITH (mode = 'AUTO')"
return "USING hnsw (embedding vector_cosine_ops)"
else:
return "USING hnsw (embedding vector_cosine_ops)"
def _pg_upgrade() -> None:
ext = _validate_extension(os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector"))
if ext in {"pgvector", "scann"}:
target = _target_index_type()
if target is None:
# pgvector — indexes are already HNSW, nothing to fix
return
target = _index_type_keyword(ext)
bind = op.get_bind()
schema_name = context.config.get_main_option("target_schema")
schema = _get_schema_prefix()
table_ref = f'"{schema_name}".memory_units' if schema_name else "memory_units"
banks_ref = f'"{schema_name}".banks' if schema_name else "banks"
using_clause = _vector_index_using_clause(ext)
using_clause = _vector_index_using_clause()
pg_schema = schema_name or "public"
rows = bind.execute(text(f"SELECT bank_id, internal_id FROM {banks_ref}")).fetchall() # noqa: S608
@@ -126,8 +117,8 @@ def _pg_upgrade() -> None:
def _pg_downgrade() -> None:
# Downgrade recreates indexes as HNSW (the original hardcoded behavior)
ext = _validate_extension(os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector"))
if ext in {"pgvector", "scann"}:
target = _target_index_type()
if target is None:
return
bind = op.get_bind()
@@ -1,91 +0,0 @@
"""Backfill entity_cooccurrences.last_cooccurred from memory_units event time
Revision ID: b5d4e3f2a1c9
Revises: o1a2b3c4d5e6
Create Date: 2026-04-24
The writer path in `entity_resolver.link_units_to_entities_batch` historically
stamped `entity_cooccurrences.last_cooccurred` with `datetime.now(UTC)` at
flush time, ignoring the source memory unit's event date. For normal online
retains that's fine (now ≈ event time), but for any corpus that was
backfilled in a single session — migrating from another memory system, for
example — every co-occurrence collapsed to the import moment, which hid the
underlying knowledge timeline from the dashboard's entity graph recency heat
and from any downstream consumer of the column.
The writer is fixed in the same change set to propagate the unit's event_date;
this migration repairs historical rows by reading the true event time off
`unit_entities × memory_units` (falling back to `created_at` when
`mentioned_at` / `occurred_start` are NULL, so rows never regress).
Oracle slot is intentionally absent: the Oracle baseline (`o1a2b3c4d5e6`)
landed days before this fix, so any Oracle deployment runs the corrected
writer against an effectively empty `entity_cooccurrences` — there is no
historical residue on Oracle to repair. PG-only matches the asymmetry of
the data, not negligence.
"""
from collections.abc import Sequence
from alembic import context, op
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "b5d4e3f2a1c9"
down_revision: str | Sequence[str] | None = "o1a2b3c4d5e6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
# Recompute last_cooccurred from the true event time per entity pair.
# COALESCE picks the first non-null of mentioned_at / occurred_start /
# created_at so banks without event-time metadata still see a sane value
# (equivalent to the pre-fix behaviour) instead of NULL.
#
# The self-join on `unit_entities` is O(k²) per memory_unit in the number
# of distinct entities mentioned (k). For typical units k is small (single
# digits), but a bank with units containing hundreds of entities and tens
# of millions of co-occurrence rows may want to run this off-hours — the
# whole UPDATE is one statement, so it locks every targeted ec row for
# the duration. The migration is one-time; subsequent online writes
# already carry event time via the writer fix.
op.execute(
f"""
UPDATE {schema}entity_cooccurrences ec
SET last_cooccurred = sub.event_time
FROM (
SELECT
LEAST(ue1.entity_id, ue2.entity_id) AS e1,
GREATEST(ue1.entity_id, ue2.entity_id) AS e2,
MAX(COALESCE(mu.mentioned_at, mu.occurred_start, mu.created_at)) AS event_time
FROM {schema}memory_units mu
JOIN {schema}unit_entities ue1 ON ue1.unit_id = mu.id
JOIN {schema}unit_entities ue2 ON ue2.unit_id = mu.id AND ue1.entity_id <> ue2.entity_id
GROUP BY 1, 2
) sub
WHERE ec.entity_id_1 = sub.e1 AND ec.entity_id_2 = sub.e2
"""
)
def _pg_downgrade() -> None:
# No-op: the previous column value was `now()` at the time of write and
# isn't recoverable. Rolling back the code is sufficient — new writes will
# revert to the old behaviour for subsequent retains.
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade) # oracle slot intentionally absent — see header
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -6,11 +6,10 @@ Create Date: 2026-03-11
This migration:
1. Adds internal_id UUID column to banks (stable identifier for index naming)
2. For non-ScaNN backends, drops the global vector index (competes with
per-bank partial indexes)
3. For non-ScaNN backends, creates per-(bank_id, fact_type) partial vector
indexes for all existing banks using the configured vector extension
(HNSW for pgvector, DiskANN for pgvectorscale, vchordrq for vchord).
2. Drops the global vector index (competes with per-bank partial indexes)
3. Creates per-(bank_id, fact_type) partial vector indexes for all existing banks
using the configured vector extension (HNSW for pgvector, DiskANN for
pgvectorscale, vchordrq for vchord).
(new banks get indexes created at bank-creation time via bank_utils.create_bank_vector_indexes)
Why per-(bank, fact_type) indexes:
@@ -18,8 +17,6 @@ Why per-(bank, fact_type) indexes:
clause, because the idx_memory_units_bank_id B-tree index always wins at planning time.
- Per-(bank, fact_type) partial indexes have both predicates matching → planner selects them.
- The global vector index competes for larger partitions (world, observation) and must be dropped.
- AlloyDB ScaNN uses global vector indexes with filtered vector search instead
because empty or tiny per-bank indexes cannot be built safely.
"""
import os
@@ -42,30 +39,22 @@ _FACT_TYPES: dict[str, str] = {
}
def _configured_vector_extension() -> str:
ext = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
if ext not in {"pgvector", "pgvectorscale", "vchord", "scann"}:
raise ValueError(
f"Invalid HINDSIGHT_API_VECTOR_EXTENSION: {ext}. Must be 'pgvector', 'vchord', 'pgvectorscale', or 'scann'"
)
return ext
def _vector_index_using_clause(ext: str) -> str:
if ext == "pgvectorscale":
return "USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)"
if ext == "vchord":
return "USING vchordrq (embedding vector_l2_ops)"
if ext == "scann":
return "USING scann (embedding cosine) WITH (mode = 'AUTO')"
return "USING hnsw (embedding vector_cosine_ops)"
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _vector_index_using_clause() -> str:
"""Return the USING clause based on the configured vector extension."""
ext = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
if ext == "pgvectorscale":
return "USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)"
elif ext == "vchord":
return "USING vchordrq (embedding vector_l2_ops)"
else:
return "USING hnsw (embedding vector_cosine_ops)"
def _pg_upgrade() -> None:
schema = _get_schema_prefix()
@@ -75,14 +64,6 @@ def _pg_upgrade() -> None:
)
op.execute(f"ALTER TABLE {schema}banks ADD CONSTRAINT banks_internal_id_unique UNIQUE (internal_id)")
ext = _configured_vector_extension()
if ext == "scann":
# ScaNN should keep/use a global vector index. Per-bank partial indexes
# are created while banks are empty and can fail AlloyDB's ScaNN build
# requirements, so this migration leaves vector index reconciliation to
# runtime ensure_vector_extension once enough rows exist.
return
# 2. Drop any fact_type-only partial indexes that may exist from prior migrations
# (bank_id B-tree always wins over them when bank_id is in the WHERE clause)
op.execute(f"DROP INDEX IF EXISTS {schema}idx_mu_emb_world")
@@ -98,7 +79,7 @@ def _pg_upgrade() -> None:
schema_name = context.config.get_main_option("target_schema")
table_ref = f'"{schema_name}".memory_units' if schema_name else "memory_units"
banks_ref = f'"{schema_name}".banks' if schema_name else "banks"
using_clause = _vector_index_using_clause(ext)
using_clause = _vector_index_using_clause()
rows = bind.execute(text(f"SELECT bank_id, internal_id FROM {banks_ref}")).fetchall() # noqa: S608
for row in rows:
@@ -128,7 +109,7 @@ def _pg_downgrade() -> None:
rows = bind.execute(text(f"SELECT internal_id FROM {banks_ref}")).fetchall() # noqa: S608
for row in rows:
internal_id = str(row[0]).replace("-", "")[:16]
for ft_short in _FACT_TYPES.values():
for ft_short in _HNSW_FACT_TYPES.values():
idx_name = f"idx_mu_emb_{ft_short}_{internal_id}"
bind.execute(text(f"DROP INDEX IF EXISTS {schema}{idx_name}"))
@@ -1,31 +0,0 @@
"""Merge divergent heads from deferrable FK and cooccurrence backfill
Revision ID: m3rg3h3ad5f6
Revises: 9f8e7d6c5b4a, b5d4e3f2a1c9
Create Date: 2026-05-04
"""
from collections.abc import Sequence
from hindsight_api.alembic._dialect import run_for_dialect
revision: str = "m3rg3h3ad5f6"
down_revision: tuple[str, ...] = ("9f8e7d6c5b4a", "b5d4e3f2a1c9")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _pg_upgrade() -> None:
pass
def _pg_downgrade() -> None:
pass
def upgrade() -> None:
run_for_dialect(pg=_pg_upgrade)
def downgrade() -> None:
run_for_dialect(pg=_pg_downgrade)
@@ -32,65 +32,53 @@ def _get_schema_prefix() -> str:
def _detect_vector_extension() -> str:
"""Detect or validate vector extension for this immutable migration revision."""
"""
Detect or validate vector extension: 'pgvector', 'vchord', or 'pgvectorscale'.
Respects HINDSIGHT_API_VECTOR_EXTENSION env var if set.
"""
conn = op.get_bind()
vector_extension = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
# Validate configured extension is installed
if vector_extension == "pgvectorscale":
# pgvectorscale/DiskANN requires pgvector
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
if not pgvector_check:
raise RuntimeError(
"DiskANN requires pgvector. Install with: CREATE EXTENSION vector; then vectorscale or pg_diskann CASCADE;"
)
# Check for either vectorscale (open source) or pg_diskann (Azure)
vectorscale_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")).scalar()
pg_diskann_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_diskann'")).scalar()
if vectorscale_check:
return "pgvectorscale"
if pg_diskann_check:
elif pg_diskann_check:
return "pg_diskann"
raise RuntimeError(
"Configured vector extension 'pgvectorscale' not found. Install either:\n"
" - pgvectorscale: CREATE EXTENSION vectorscale CASCADE;\n"
" - pg_diskann (Azure): CREATE EXTENSION pg_diskann CASCADE;"
)
if vector_extension == "vchord":
else:
raise RuntimeError(
"Configured vector extension 'pgvectorscale' not found. Install either:\n"
" - pgvectorscale: CREATE EXTENSION vectorscale CASCADE;\n"
" - pg_diskann (Azure): CREATE EXTENSION pg_diskann CASCADE;"
)
elif vector_extension == "vchord":
vchord_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vchord'")).scalar()
if not vchord_check:
raise RuntimeError(
"Configured vector extension 'vchord' not found. Install it with: CREATE EXTENSION vchord CASCADE;"
)
return "vchord"
if vector_extension == "scann":
scann_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'alloydb_scann'")).scalar()
if not scann_check:
raise RuntimeError(
"Configured vector extension 'scann' not found. Install it with: CREATE EXTENSION alloydb_scann CASCADE;"
)
return "scann"
if vector_extension == "pgvector":
elif vector_extension == "pgvector":
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
if not pgvector_check:
raise RuntimeError(
"Configured vector extension 'pgvector' not found. Install it with: CREATE EXTENSION vector;"
)
return "pgvector"
raise ValueError(
"Invalid HINDSIGHT_API_VECTOR_EXTENSION: "
f"{vector_extension}. Must be 'pgvector', 'vchord', 'pgvectorscale', or 'scann'"
)
def _vector_index_using_clause(ext: str) -> str:
if ext == "pgvectorscale":
return "USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)"
if ext == "pg_diskann":
return "USING diskann (embedding vector_cosine_ops) WITH (max_neighbors = 50)"
if ext == "vchord":
return "USING vchordrq (embedding vector_l2_ops)"
if ext == "scann":
return "USING scann (embedding cosine) WITH (mode = 'AUTO')"
return "USING hnsw (embedding vector_cosine_ops)"
else:
raise ValueError(
f"Invalid HINDSIGHT_API_VECTOR_EXTENSION: {vector_extension}. Must be 'pgvector', 'vchord', or 'pgvectorscale'"
)
def _detect_text_search_extension() -> str:
@@ -170,12 +158,28 @@ def _pg_upgrade() -> None:
# Indexes for learnings
op.execute(f"CREATE INDEX idx_learnings_bank_id ON {schema}learnings(bank_id)")
# Create vector index based on detected extension. ScaNN is deferred because
# this table is empty during migration and AlloyDB rejects empty ScaNN builds.
if vector_ext != "scann":
# Create vector index based on detected extension
if vector_ext == "pgvectorscale":
op.execute(f"""
CREATE INDEX idx_learnings_embedding ON {schema}learnings
{_vector_index_using_clause(vector_ext)}
USING diskann (embedding vector_cosine_ops)
WITH (num_neighbors = 50)
""")
elif vector_ext == "pg_diskann":
op.execute(f"""
CREATE INDEX idx_learnings_embedding ON {schema}learnings
USING diskann (embedding vector_cosine_ops)
WITH (max_neighbors = 50)
""")
elif vector_ext == "vchord":
op.execute(f"""
CREATE INDEX idx_learnings_embedding ON {schema}learnings
USING vchordrq (embedding vector_l2_ops)
""")
else: # pgvector
op.execute(f"""
CREATE INDEX idx_learnings_embedding ON {schema}learnings
USING hnsw (embedding vector_cosine_ops)
""")
op.execute(f"CREATE INDEX idx_learnings_tags ON {schema}learnings USING GIN(tags)")
@@ -233,12 +237,28 @@ def _pg_upgrade() -> None:
# Indexes for pinned_reflections
op.execute(f"CREATE INDEX idx_pinned_reflections_bank_id ON {schema}pinned_reflections(bank_id)")
# Create vector index based on detected extension. ScaNN is deferred because
# this table is empty during migration and AlloyDB rejects empty ScaNN builds.
if vector_ext != "scann":
# Create vector index based on detected extension
if vector_ext == "pgvectorscale":
op.execute(f"""
CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections
{_vector_index_using_clause(vector_ext)}
USING diskann (embedding vector_cosine_ops)
WITH (num_neighbors = 50)
""")
elif vector_ext == "pg_diskann":
op.execute(f"""
CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections
USING diskann (embedding vector_cosine_ops)
WITH (max_neighbors = 50)
""")
elif vector_ext == "vchord":
op.execute(f"""
CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections
USING vchordrq (embedding vector_l2_ops)
""")
else: # pgvector
op.execute(f"""
CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections
USING hnsw (embedding vector_cosine_ops)
""")
op.execute(f"CREATE INDEX idx_pinned_reflections_tags ON {schema}pinned_reflections USING GIN(tags)")
+1 -5
View File
@@ -997,7 +997,7 @@ class BackgroundResponse(BaseModel):
class BankListItem(BaseModel):
"""Bank list item with profile summary and stats."""
"""Bank list item with profile summary."""
bank_id: str
name: str | None = None
@@ -1005,8 +1005,6 @@ class BankListItem(BaseModel):
mission: str | None = None
created_at: str | None = None
updated_at: str | None = None
fact_count: int = 0
last_document_at: str | None = None
class BankListResponse(BaseModel):
@@ -1023,8 +1021,6 @@ class BankListResponse(BaseModel):
"mission": "I am a software engineer helping my team ship quality code",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-16T14:20:00Z",
"fact_count": 156,
"last_document_at": "2024-01-16T14:20:00Z",
}
]
}
+10 -77
View File
@@ -14,7 +14,6 @@ from typing import Any, Literal
from dotenv import find_dotenv, load_dotenv
from ._vector_index import validate_extension
from .utils import mask_network_location
# Load .env file, searching current and parent directories (overrides existing env vars)
@@ -122,9 +121,6 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]:
# Environment variable names
ENV_DATABASE_BACKEND = "HINDSIGHT_API_DATABASE_BACKEND"
ENV_DATABASE_URL = "HINDSIGHT_API_DATABASE_URL"
ENV_READ_DATABASE_URL = "HINDSIGHT_API_READ_DATABASE_URL"
ENV_READ_DB_POOL_MIN_SIZE = "HINDSIGHT_API_READ_DB_POOL_MIN_SIZE"
ENV_READ_DB_POOL_MAX_SIZE = "HINDSIGHT_API_READ_DB_POOL_MAX_SIZE"
ENV_MIGRATION_DATABASE_URL = "HINDSIGHT_API_MIGRATION_DATABASE_URL"
ENV_DATABASE_SCHEMA = "HINDSIGHT_API_DATABASE_SCHEMA"
ENV_LLM_PROVIDER = "HINDSIGHT_API_LLM_PROVIDER"
@@ -139,23 +135,11 @@ ENV_LLM_TIMEOUT = "HINDSIGHT_API_LLM_TIMEOUT"
ENV_LLM_GROQ_SERVICE_TIER = "HINDSIGHT_API_LLM_GROQ_SERVICE_TIER"
ENV_LLM_OPENAI_SERVICE_TIER = "HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER"
ENV_LLM_EXTRA_BODY = "HINDSIGHT_API_LLM_EXTRA_BODY"
ENV_LLM_DEFAULT_HEADERS = "HINDSIGHT_API_LLM_DEFAULT_HEADERS"
# LiteLLM Router chain — provider-specific config consumed by the "litellmrouter"
# provider. Each entry is a deployment; the Router tries them in declared order and
# falls back to the next on transient errors (5xx, rate-limit, timeout).
# Provider-scoped naming mirrors other provider-specific flags (e.g. llm_groq_*,
# llm_vertexai_*). Note the single token "LITELLMROUTER" — keeping it one word
# disambiguates from the embeddings/reranker LITELLM_* settings.
ENV_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG"
# 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_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
)
# Per-operation LLM configuration (optional, falls back to global LLM config)
ENV_RETAIN_LLM_PROVIDER = "HINDSIGHT_API_RETAIN_LLM_PROVIDER"
@@ -167,7 +151,6 @@ ENV_RETAIN_LLM_MAX_RETRIES = "HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES"
ENV_RETAIN_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_RETAIN_LLM_INITIAL_BACKOFF"
ENV_RETAIN_LLM_MAX_BACKOFF = "HINDSIGHT_API_RETAIN_LLM_MAX_BACKOFF"
ENV_RETAIN_LLM_TIMEOUT = "HINDSIGHT_API_RETAIN_LLM_TIMEOUT"
ENV_RETAIN_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_RETAIN_LLM_LITELLMROUTER_CONFIG"
ENV_REFLECT_LLM_PROVIDER = "HINDSIGHT_API_REFLECT_LLM_PROVIDER"
ENV_REFLECT_LLM_API_KEY = "HINDSIGHT_API_REFLECT_LLM_API_KEY"
@@ -178,7 +161,6 @@ ENV_REFLECT_LLM_MAX_RETRIES = "HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES"
ENV_REFLECT_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_REFLECT_LLM_INITIAL_BACKOFF"
ENV_REFLECT_LLM_MAX_BACKOFF = "HINDSIGHT_API_REFLECT_LLM_MAX_BACKOFF"
ENV_REFLECT_LLM_TIMEOUT = "HINDSIGHT_API_REFLECT_LLM_TIMEOUT"
ENV_REFLECT_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_REFLECT_LLM_LITELLMROUTER_CONFIG"
ENV_CONSOLIDATION_LLM_PROVIDER = "HINDSIGHT_API_CONSOLIDATION_LLM_PROVIDER"
ENV_CONSOLIDATION_LLM_API_KEY = "HINDSIGHT_API_CONSOLIDATION_LLM_API_KEY"
@@ -189,7 +171,6 @@ ENV_CONSOLIDATION_LLM_MAX_RETRIES = "HINDSIGHT_API_CONSOLIDATION_LLM_MAX_RETRIES
ENV_CONSOLIDATION_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_CONSOLIDATION_LLM_INITIAL_BACKOFF"
ENV_CONSOLIDATION_LLM_MAX_BACKOFF = "HINDSIGHT_API_CONSOLIDATION_LLM_MAX_BACKOFF"
ENV_CONSOLIDATION_LLM_TIMEOUT = "HINDSIGHT_API_CONSOLIDATION_LLM_TIMEOUT"
ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG = "HINDSIGHT_API_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG"
ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
@@ -470,8 +451,6 @@ PROVIDER_DEFAULT_MODELS = {
"groq": "openai/gpt-oss-120b",
"minimax": "MiniMax-M2.7",
"deepseek": "deepseek-v4-flash",
"zai": "glm-4.5-flash",
"opencode-go": "deepseek-v4-flash",
"ollama": "gemma3:12b",
"llamacpp": "gemma-4-e2b-it",
"lmstudio": "local-model",
@@ -509,7 +488,7 @@ DEFAULT_LLM_GEMINI_SAFETY_SETTINGS = None # None = use Gemini default safety se
DEFAULT_EMBEDDINGS_PROVIDER = "local"
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings (avoids MPS/XPC issues on macOS)
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE = False # Security: disabled by default, required for some models
DEFAULT_EMBEDDINGS_OPENAI_MODEL = "text-embedding-3-small"
DEFAULT_EMBEDDINGS_OPENAI_BATCH_SIZE = 100
@@ -520,7 +499,7 @@ DEFAULT_EMBEDDING_DIMENSION = 384
DEFAULT_RERANKER_PROVIDER = "local"
DEFAULT_RERANKER_LOCAL_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
DEFAULT_RERANKER_LOCAL_FORCE_CPU = False # Force CPU mode for local reranker
DEFAULT_RERANKER_LOCAL_FORCE_CPU = False # Force CPU mode for local reranker (avoids MPS/XPC issues on macOS)
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT = 4 # Limit concurrent CPU-bound reranking to prevent thrashing
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE = (
False # Security: disabled by default, required for some models like jina-reranker-v2
@@ -550,8 +529,8 @@ DEFAULT_RERANKER_SILICONFLOW_BASE_URL = "https://api.siliconflow.cn/v1"
DEFAULT_RERANKER_GOOGLE_MODEL = "semantic-ranker-default-004"
# Vector extension (pgvector, vchord, pgvectorscale, or AlloyDB ScaNN)
DEFAULT_VECTOR_EXTENSION = "pgvector" # Options: "pgvector", "vchord", "pgvectorscale", "scann"
# Vector extension (pgvector, vchord, or pgvectorscale)
DEFAULT_VECTOR_EXTENSION = "pgvector" # Options: "pgvector", "vchord", "pgvectorscale"
# Text search extension (native PostgreSQL, vchord BM25, or Timescale pg_textsearch)
DEFAULT_TEXT_SEARCH_EXTENSION = "native" # Options: "native", "vchord", "pg_textsearch"
@@ -821,24 +800,6 @@ def _get_default_model_for_provider(provider: str) -> str:
return PROVIDER_DEFAULT_MODELS.get(provider.lower(), DEFAULT_LLM_MODEL)
def _parse_llm_router_config(env_var: str) -> dict | None:
"""
Parse a LiteLLM Router configuration from a JSON env var.
The value is forwarded verbatim to ``litellm.Router(**config)``. We only
check that it parses as JSON; LiteLLM Router is authoritative about the
shape (``model_list``, ``fallbacks``, ``routing_strategy``, …). See
https://docs.litellm.ai/docs/routing.
"""
raw = os.getenv(env_var, "").strip()
if not raw:
return None
try:
return json.loads(raw)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid {env_var}: invalid JSON: {e}") from e
def _parse_default_bank_template(raw: str | None) -> dict | None:
"""
Parse HINDSIGHT_API_DEFAULT_BANK_TEMPLATE as JSON.
@@ -866,14 +827,9 @@ class HindsightConfig:
# Database
database_backend: Literal["postgresql", "oracle"]
database_url: str
# Optional read-replica URL for recall queries. When set, the engine opens
# a second pool and routes recall SELECTs through it.
read_database_url: str | None
read_db_pool_min_size: int
read_db_pool_max_size: int
migration_database_url: str | None
database_schema: str
vector_extension: str # "pgvector", "vchord", "pgvectorscale", or "scann"
vector_extension: str # "pgvector" or "vchord"
text_search_extension: str # "native" or "vchord"
# LLM (default, used as fallback for per-operation config)
@@ -891,15 +847,6 @@ class HindsightConfig:
llm_extra_body: (
dict | None
) # Extra body params merged into OpenAI-compatible API calls (e.g. {"chat_template_kwargs": {"enable_thinking": true}})
llm_default_headers: (
dict | None
) # Custom headers passed as default_headers to provider SDK clients (e.g. {"X-Component-Id": "hindsight"} for proxies / request tracing)
# LiteLLM Router chain (provider-specific; consumed by the "litellmrouter" provider).
# List of deployment dicts evaluated in order with fallback on transient errors.
# Each entry: {"provider": str, "model": str, "api_key": str | None, "base_url": str | None}.
# Treated as a credential field because entries embed api keys.
llm_litellmrouter_config: dict | None
# Vertex AI configuration
llm_vertexai_project_id: str | None
@@ -927,7 +874,6 @@ class HindsightConfig:
retain_llm_initial_backoff: float | None
retain_llm_max_backoff: float | None
retain_llm_timeout: float | None
retain_llm_litellmrouter_config: dict | None
reflect_llm_provider: str | None
reflect_llm_api_key: str | None
@@ -938,7 +884,6 @@ class HindsightConfig:
reflect_llm_initial_backoff: float | None
reflect_llm_max_backoff: float | None
reflect_llm_timeout: float | None
reflect_llm_litellmrouter_config: dict | None
consolidation_llm_provider: str | None
consolidation_llm_api_key: str | None
@@ -949,7 +894,6 @@ class HindsightConfig:
consolidation_llm_initial_backoff: float | None
consolidation_llm_max_backoff: float | None
consolidation_llm_timeout: float | None
consolidation_llm_litellmrouter_config: dict | None
# Embeddings
embeddings_provider: str
@@ -1189,11 +1133,6 @@ class HindsightConfig:
"retain_llm_api_key",
"reflect_llm_api_key",
"consolidation_llm_api_key",
# LiteLLM Router chains — entries embed api_keys and base_urls
"llm_litellmrouter_config",
"retain_llm_litellmrouter_config",
"reflect_llm_litellmrouter_config",
"consolidation_llm_litellmrouter_config",
# Base URLs (could expose infrastructure)
"llm_base_url",
"retain_llm_base_url",
@@ -1331,7 +1270,11 @@ class HindsightConfig:
def validate(self) -> None:
"""Validate configuration values and raise errors for invalid combinations."""
# Validate vector_extension
validate_extension(self.vector_extension)
valid_extensions = ("pgvector", "vchord", "pgvectorscale")
if self.vector_extension not in valid_extensions:
raise ValueError(
f"Invalid vector_extension: {self.vector_extension}. Must be one of: {', '.join(valid_extensions)}"
)
# Validate text_search_extension
valid_text_search = ("native", "vchord", "pg_textsearch")
@@ -1409,9 +1352,6 @@ class HindsightConfig:
# Database
database_backend=os.getenv(ENV_DATABASE_BACKEND, DEFAULT_DATABASE_BACKEND).lower(),
database_url=os.getenv(ENV_DATABASE_URL, DEFAULT_DATABASE_URL),
read_database_url=os.getenv(ENV_READ_DATABASE_URL) or None,
read_db_pool_min_size=int(os.getenv(ENV_READ_DB_POOL_MIN_SIZE, str(DEFAULT_DB_POOL_MIN_SIZE))),
read_db_pool_max_size=int(os.getenv(ENV_READ_DB_POOL_MAX_SIZE, str(DEFAULT_DB_POOL_MAX_SIZE))),
migration_database_url=os.getenv(ENV_MIGRATION_DATABASE_URL) or None,
database_schema=os.getenv(ENV_DATABASE_SCHEMA, DEFAULT_DATABASE_SCHEMA),
vector_extension=os.getenv(ENV_VECTOR_EXTENSION, DEFAULT_VECTOR_EXTENSION).lower(),
@@ -1429,8 +1369,6 @@ class HindsightConfig:
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_extra_body=json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null")),
llm_default_headers=json.loads(os.getenv(ENV_LLM_DEFAULT_HEADERS, "null")),
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,
llm_vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION, DEFAULT_LLM_VERTEXAI_REGION),
@@ -1469,7 +1407,6 @@ class HindsightConfig:
if os.getenv(ENV_RETAIN_LLM_MAX_BACKOFF)
else None,
retain_llm_timeout=float(os.getenv(ENV_RETAIN_LLM_TIMEOUT)) if os.getenv(ENV_RETAIN_LLM_TIMEOUT) else None,
retain_llm_litellmrouter_config=_parse_llm_router_config(ENV_RETAIN_LLM_LITELLMROUTER_CONFIG),
reflect_llm_provider=os.getenv(ENV_REFLECT_LLM_PROVIDER) or None,
reflect_llm_api_key=os.getenv(ENV_REFLECT_LLM_API_KEY) or None,
reflect_llm_model=os.getenv(ENV_REFLECT_LLM_MODEL)
@@ -1494,7 +1431,6 @@ class HindsightConfig:
reflect_llm_timeout=float(os.getenv(ENV_REFLECT_LLM_TIMEOUT))
if os.getenv(ENV_REFLECT_LLM_TIMEOUT)
else None,
reflect_llm_litellmrouter_config=_parse_llm_router_config(ENV_REFLECT_LLM_LITELLMROUTER_CONFIG),
consolidation_llm_provider=os.getenv(ENV_CONSOLIDATION_LLM_PROVIDER) or None,
consolidation_llm_api_key=os.getenv(ENV_CONSOLIDATION_LLM_API_KEY) or None,
consolidation_llm_model=os.getenv(ENV_CONSOLIDATION_LLM_MODEL)
@@ -1519,7 +1455,6 @@ class HindsightConfig:
consolidation_llm_timeout=float(os.getenv(ENV_CONSOLIDATION_LLM_TIMEOUT))
if os.getenv(ENV_CONSOLIDATION_LLM_TIMEOUT)
else None,
consolidation_llm_litellmrouter_config=_parse_llm_router_config(ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG),
# Embeddings
embeddings_provider=os.getenv(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER),
embeddings_local_model=os.getenv(ENV_EMBEDDINGS_LOCAL_MODEL, DEFAULT_EMBEDDINGS_LOCAL_MODEL),
@@ -1945,8 +1880,6 @@ class HindsightConfig:
def log_config(self) -> None:
"""Log the current configuration (without sensitive values)."""
logger.info(f"Database: {mask_network_location(self.database_url)} (schema: {self.database_schema})")
if self.read_database_url:
logger.info(f"Read database (recall queries only): {mask_network_location(self.read_database_url)}")
if self.migration_database_url:
logger.info(f"Migration database: {mask_network_location(self.migration_database_url)}")
logger.info(f"LLM: provider={self.llm_provider}, model={self.llm_model}")
+34 -96
View File
@@ -4,20 +4,12 @@ Daemon mode support for Hindsight API.
Provides idle timeout for running as a background daemon.
"""
from __future__ import annotations
import asyncio
import logging
import os
import platform
import subprocess
import sys
import time
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import IO
logger = logging.getLogger(__name__)
@@ -28,12 +20,6 @@ DEFAULT_IDLE_TIMEOUT = 0 # 0 = no auto-exit (hindsight-embed passes its own tim
# Allow override via environment variable for profile-specific logs
DAEMON_LOG_PATH = Path(os.getenv("HINDSIGHT_API_DAEMON_LOG", str(Path.home() / ".hindsight" / "daemon.log")))
# Internal env var: set by daemonize() in the re-exec'd child so the child
# skips re-exec and just redirects stdio. Also set by hindsight-embed's
# DaemonEmbedManager so the daemon launched via Popen skips re-exec entirely
# (hindsight-embed's Popen already provides a clean, detached process).
ENV_DAEMON_CHILD = "_HINDSIGHT_DAEMON_CHILD"
class IdleTimeoutMiddleware:
"""ASGI middleware that tracks activity and exits after idle timeout."""
@@ -72,103 +58,55 @@ class IdleTimeoutMiddleware:
os.kill(os.getpid(), signal.SIGTERM)
def _detach_popen_kwargs(log_handle: "IO[bytes]") -> dict:
"""Cross-platform kwargs to spawn a subprocess detached from the caller.
On POSIX, ``start_new_session=True`` calls ``setsid(2)`` so the child
survives the parent's terminal. On Windows we use
``DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP``.
``log_handle`` receives the child's stdout/stderr so output never leaks
into the parent's terminal.
"""
if platform.system() == "Windows":
detached_process = getattr(subprocess, "DETACHED_PROCESS", 0)
create_new_process_group = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
return {
"creationflags": detached_process | create_new_process_group,
"stdin": subprocess.DEVNULL,
"stdout": log_handle,
"stderr": subprocess.STDOUT,
"close_fds": True,
}
return {
"start_new_session": True,
"stdin": subprocess.DEVNULL,
"stdout": log_handle,
"stderr": log_handle,
}
def _redirect_stdio_to_log() -> None:
"""Redirect stdin/stdout/stderr to the daemon log file.
Called in the daemon child process after re-exec.
"""
DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
sys.stdout.flush()
sys.stderr.flush()
with open(os.devnull, "r") as devnull:
os.dup2(devnull.fileno(), sys.stdin.fileno())
log_fd = open(DAEMON_LOG_PATH, "a")
os.dup2(log_fd.fileno(), sys.stdout.fileno())
os.dup2(log_fd.fileno(), sys.stderr.fileno())
def daemonize():
"""Detach the current process into a background daemon.
"""
Fork the current process into a background daemon.
Uses ``subprocess.Popen`` (which maps to ``posix_spawn`` on macOS) to
re-exec the current command in a detached session. This replaces the
traditional double-fork pattern because ``os.fork()`` without ``exec()``
corrupts Apple framework state (XPC, Metal/MPS, ObjC runtime) on macOS,
causing SIGBUS crashes when PyTorch uses the MPS backend.
The function has two code paths controlled by the ``_HINDSIGHT_DAEMON_CHILD``
environment variable:
* **Parent** (env var not set): re-exec the same command via Popen with
``start_new_session=True``, stripping ``--daemon`` from argv and setting
``_HINDSIGHT_DAEMON_CHILD=1``. Then ``sys.exit(0)``.
* **Child** (env var set): redirect stdio to the daemon log file and return.
No fork, no re-exec.
Uses double-fork technique to properly detach from terminal.
On Windows there is no fork model: the spawning parent is expected to
detach us via ``CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS`` and to
redirect stdout/stderr to ``HINDSIGHT_API_DAEMON_LOG`` before exec.
We still ensure the log directory exists.
detach us via `CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS` and to
redirect stdout/stderr to HINDSIGHT_API_DAEMON_LOG before exec. We
still ensure the log directory exists so that any file handlers set
up by the calling app have a valid target.
"""
if sys.platform == "win32":
DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
return
# If we are already the daemon child (re-exec'd by a previous daemonize()
# call, or launched by hindsight-embed with the env var set), just redirect
# stdio and return — no re-exec needed.
if os.environ.get(ENV_DAEMON_CHILD) == "1":
_redirect_stdio_to_log()
return
# First fork - detach from parent
try:
pid = os.fork()
if pid > 0:
sys.exit(0)
except OSError as e:
sys.stderr.write(f"fork #1 failed: {e}\n")
sys.exit(1)
# --- Parent path: re-exec ourselves as a detached background process ---
# Decouple from parent environment
os.chdir("/")
os.setsid()
os.umask(0)
# Build child command: same Python, same module entry point, all args
# except --daemon (replaced by the env var).
child_args = [a for a in sys.argv[1:] if a != "--daemon"]
cmd = [sys.executable, "-m", "hindsight_api.main"] + child_args
env = os.environ.copy()
env[ENV_DAEMON_CHILD] = "1"
env["HINDSIGHT_API_DAEMON_LOG"] = str(DAEMON_LOG_PATH)
# Second fork - prevent zombie
pid = os.fork()
if pid > 0:
sys.exit(0)
# Redirect standard file descriptors to log file
DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(DAEMON_LOG_PATH, "ab") as log_handle:
subprocess.Popen(cmd, env=env, **_detach_popen_kwargs(log_handle))
sys.stdout.flush()
sys.stderr.flush()
sys.exit(0)
# Redirect stdin to /dev/null
with open("/dev/null", "r") as devnull:
os.dup2(devnull.fileno(), sys.stdin.fileno())
# Redirect stdout/stderr to log file
log_fd = open(DAEMON_LOG_PATH, "a")
os.dup2(log_fd.fileno(), sys.stdout.fileno())
os.dup2(log_fd.fileno(), sys.stderr.fileno())
def check_daemon_running(port: int = DEFAULT_DAEMON_PORT) -> bool:
@@ -133,7 +133,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
Default: cross-encoder/ms-marco-MiniLM-L-6-v2
max_concurrent: Maximum concurrent reranking calls (default: 2).
Higher values may cause CPU thrashing under load.
force_cpu: Force CPU mode for local inference.
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
Default: False
trust_remote_code: Allow loading models with custom code (security risk).
Required for some models like jina-reranker-v2-base-multilingual.
@@ -1,181 +0,0 @@
"""Registry of optional PostgreSQL server-side routines.
Some hot paths (currently only the worker poller's per-cycle scan) can be
sped up by a server-side PL/pgSQL routine that the API never installs
itself — operators install it out-of-band (e.g. a Helm hook in
hindsight-cloud) when they want the optimisation. When the routine is not
installed, callers must fall back to a pure-Python implementation.
This module centralises that pattern so we don't sprinkle ad-hoc
``try / except`` blocks (which silently log a server-side error on every
call) around the codebase. Each registered entry carries:
* a ``schema`` and ``name`` (used to probe ``pg_proc``)
* a ``contract`` describing the expected signature and return shape
Bodies are deliberately not stored here. Hindsight never installs these
routines, so a body checked into this repo would (a) drift from whatever
operators actually deploy and (b) imply ownership we don't have. The
contract is the entire API surface: any operator-supplied implementation
that satisfies it is interchangeable.
Probe behaviour:
* On first ``is_installed()`` call per backend instance we issue a single
``SELECT EXISTS(...) FROM pg_proc`` and cache the boolean result in
memory for the life of the process.
* No TTL: if an operator installs a routine on a running cluster, workers
pick it up only after restart. This is intentional — these routines are
expected to be installed once at deploy time, and a probe-per-poll would
defeat the optimisation.
* Non-PostgreSQL backends short-circuit to ``False`` without touching the
database, so callers can use the same code path for Oracle.
PostgreSQL terminology note: ``CREATE FUNCTION ... RETURNS SETOF`` defines
a *function* (invoked via ``SELECT``); ``CREATE PROCEDURE`` defines a
*procedure* (invoked via ``CALL``). The SQL-standard umbrella term
covering both is *routine*, and the system catalog (``pg_proc``) stores
both. The module name uses "routine" so a future ``CREATE PROCEDURE``
entry slots in without a rename.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .base import DatabaseBackend, DatabaseConnection
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class OptionalRoutine:
"""One optional server-side routine Hindsight may call when present.
Hindsight never installs these — operators do, out-of-band. The fields
here describe the contract the API expects; any implementation that
matches is interchangeable.
Attributes:
name: Unqualified routine name as it appears in ``pg_proc.proname``.
schema: Schema the routine lives in (matched against
``pg_namespace.nspname``).
contract: Free-form description of the expected signature,
arguments, return type, and any semantic constraints. Read
this before installing a custom implementation.
"""
name: str
schema: str
contract: str
# Registry of known optional routines.
#
# Add new entries here when a hot path grows a server-side optimisation.
# Document the *contract* — not the implementation — so operator-supplied
# variants stay interchangeable and we don't pretend to own SQL we never
# install.
SCHEMAS_WITH_PENDING_WORK = OptionalRoutine(
name="schemas_with_pending_work",
schema="public",
contract="""
Signature: public.schemas_with_pending_work() RETURNS SETOF text
Called by the worker poller on every cycle to find schemas with
claimable async_operations rows. The poller then runs FOR UPDATE
SKIP LOCKED only against the returned schemas, so an empty set means
"nothing to do, skip the expensive claim query".
A schema is "claimable" iff at least one row matches:
status = 'pending' AND task_payload IS NOT NULL
in that schema's ``async_operations`` table.
Required semantics:
* No arguments.
* Returns a set of schema names (``text``); each must match a real
``pg_namespace.nspname``. The poller passes them straight into
the claim query — anything that isn't a valid schema will fail.
* Operators choose the search scope (e.g. ``tenant_%`` only, or
include ``public``). A schema omitted from the scan will *never*
be serviced by the poller, so the implementation must cover
every schema that holds an ``async_operations`` table in that
deployment.
* Should be cheap and idempotent — called every poll cycle (~30s).
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
of tenant schemas; everything else works correctly without it.
""",
)
_REGISTRY: dict[str, OptionalRoutine] = {
SCHEMAS_WITH_PENDING_WORK.name: SCHEMAS_WITH_PENDING_WORK,
}
class OptionalRoutines:
"""Per-backend cache of which optional routines are installed.
One instance per long-lived consumer (e.g. one per ``WorkerPoller``).
Probes ``pg_proc`` lazily on first lookup and caches the result in
memory until the process restarts.
"""
def __init__(self, backend: DatabaseBackend) -> None:
self._backend = backend
self._cache: dict[str, bool] = {}
async def is_installed(self, conn: DatabaseConnection, routine_name: str) -> bool:
"""Return True iff *routine_name* exists in ``pg_proc``.
On non-PostgreSQL backends always returns False without issuing a
query. Result is memoised for the life of this instance.
"""
if self._backend.backend_type != "postgresql":
return False
cached = self._cache.get(routine_name)
if cached is not None:
return cached
routine = _REGISTRY.get(routine_name)
if routine is None:
raise KeyError(f"Unknown optional routine: {routine_name!r}")
exists = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM pg_proc p "
"JOIN pg_namespace n ON p.pronamespace = n.oid "
"WHERE n.nspname = $1 AND p.proname = $2)",
routine.schema,
routine.name,
)
installed = bool(exists)
self._cache[routine_name] = installed
if installed:
logger.info(
"Optional PG routine %s.%s detected — using server-side path",
routine.schema,
routine.name,
)
else:
logger.debug(
"Optional PG routine %s.%s not installed — using fallback path",
routine.schema,
routine.name,
)
return installed
def invalidate(self, routine_name: str | None = None) -> None:
"""Drop cached probe results (test helper).
Without an argument, clears the entire cache.
"""
if routine_name is None:
self._cache.clear()
else:
self._cache.pop(routine_name, None)
@@ -104,7 +104,7 @@ class LocalSTEmbeddings(Embeddings):
Args:
model_name: Name of the SentenceTransformer model to use.
Default: BAAI/bge-small-en-v1.5
force_cpu: Force CPU mode for local inference.
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
Default: False
trust_remote_code: Allow loading models with custom code (security risk).
Required for some models with custom architectures.
@@ -12,7 +12,7 @@ from collections import defaultdict
from dataclasses import dataclass, field
from datetime import UTC, datetime
from difflib import SequenceMatcher
from typing import Any, Final
from typing import Any
from .db_utils import acquire_with_retry
from .memory_engine import fq_table
@@ -46,38 +46,12 @@ class _EntityStatAgg:
max_date: datetime | None = None
# Sentinel distinguishing "key not in dict" from "key present with value None".
# Needed when merging event_date across duplicate unit rows: legacy two-tuple
# callers surface `None`, which must not clobber a real datetime from another
# caller for the same unit.
_SENTINEL_MISSING: Final = object()
def _later_date(a: datetime | None, b: datetime | None) -> datetime | None:
"""Return whichever of ``a`` / ``b`` is later (None loses to any datetime).
Used to fold duplicate co-occurrence pairs across a retain batch: legacy
two-tuple callers surface ``None``, which must not clobber a real
datetime that arrived for the same pair from an aware caller.
"""
if a is None:
return b
if b is None:
return a
return a if a > b else b
@dataclass
class _CooccurrencePair:
"""A (entity_id_1, entity_id_2) pair observed in a retain batch (for post-txn flush)."""
entity_id_1: str
entity_id_2: str
# When the two entities co-occurred in the source content. For real-time
# retains this is ~now; for backfilled corpora it's the historical event
# time, so the cooccurrence cache reflects the underlying knowledge
# timeline instead of collapsing to the import moment.
event_date: datetime | None = None
# Load spaCy model (singleton)
@@ -169,15 +143,11 @@ class EntityResolver:
)
if cooccurrences:
# Aggregate per (entity_id_1, entity_id_2): count occurrences and
# keep the latest event_date we saw. Using GREATEST(...) in the SQL
# already handles merging against the existing row; here we fold
# the batch so executemany doesn't send the same pair twice.
coo_agg: dict[tuple[str, str], tuple[int, datetime | None]] = {}
# Aggregate: count occurrences per (entity_id_1, entity_id_2) pair.
coo_agg: dict[tuple[str, str], int] = {}
for c in cooccurrences:
pair = (c.entity_id_1, c.entity_id_2)
prev_count, prev_date = coo_agg.get(pair, (0, None))
coo_agg[pair] = (prev_count + 1, _later_date(prev_date, c.event_date))
coo_agg[pair] = coo_agg.get(pair, 0) + 1
now = datetime.now(UTC)
# Sort by (entity_id_1, entity_id_2) for consistent lock ordering.
@@ -191,7 +161,7 @@ class EntityResolver:
cooccurrence_count = {fq_table("entity_cooccurrences")}.cooccurrence_count + EXCLUDED.cooccurrence_count,
last_cooccurred = GREATEST({fq_table("entity_cooccurrences")}.last_cooccurred, EXCLUDED.last_cooccurred)
""",
sorted((e1, e2, count, event_date or now) for (e1, e2), (count, event_date) in coo_agg.items()),
sorted((e1, e2, count, now) for (e1, e2), count in coo_agg.items()),
)
@staticmethod
@@ -902,45 +872,29 @@ class EntityResolver:
entity_id_2,
)
async def link_units_to_entities_batch(
self,
unit_entity_pairs: list[tuple[str, str]] | list[tuple[str, str, datetime | None]],
conn=None,
):
async def link_units_to_entities_batch(self, unit_entity_pairs: list[tuple[str, str]], conn=None):
"""
Link multiple memory units to entities in batch (MUCH faster than sequential).
Also updates co-occurrence cache for entities that appear in the same unit.
Args:
unit_entity_pairs: List of (unit_id, entity_id) or
(unit_id, entity_id, event_date) tuples. When `event_date` is
supplied, ``entity_cooccurrences.last_cooccurred`` for pairs
observed in that unit advances to the event time instead of
``now()``, which matters for backfilled corpora where ingest
time is a single spike unrelated to the underlying timeline.
Legacy two-tuples remain accepted.
unit_entity_pairs: List of (unit_id, entity_id) tuples
conn: Optional connection to use (if None, acquires from pool)
"""
if not unit_entity_pairs:
return
# Normalize to 3-tuples internally so downstream code doesn't branch.
normalized: list[tuple[str, str, datetime | None]] = [
(t[0], t[1], t[2] if len(t) >= 3 else None) # type: ignore[misc]
for t in unit_entity_pairs
]
if conn is None:
async with acquire_with_retry(self.pool) as conn:
return await self._link_units_to_entities_batch_impl(conn, normalized)
return await self._link_units_to_entities_batch_impl(conn, unit_entity_pairs)
else:
return await self._link_units_to_entities_batch_impl(conn, normalized)
return await self._link_units_to_entities_batch_impl(conn, unit_entity_pairs)
async def _link_units_to_entities_batch_impl(self, conn, unit_entity_pairs: list[tuple[str, str, datetime | None]]):
async def _link_units_to_entities_batch_impl(self, conn, unit_entity_pairs: list[tuple[str, str]]):
# Sorted bulk insert to prevent deadlocks from inconsistent lock ordering
# across concurrent transactions on the unit_entities unique index.
sorted_pairs = sorted(unit_entity_pairs, key=lambda t: (t[0], t[1]))
sorted_pairs = sorted(unit_entity_pairs)
unit_ids = [p[0] for p in sorted_pairs]
entity_ids = [p[1] for p in sorted_pairs]
@@ -951,42 +905,28 @@ class EntityResolver:
entity_ids,
)
# Build maps keyed by unit_id:
# unit_to_entities: entity set per unit (for the co-occurrence cross-product)
# unit_event_date: event time per unit (propagated onto every pair from that unit)
# When a unit shows up more than once with conflicting event_dates (legacy
# callers passing None interleaved with aware callers), prefer the first
# non-None value so we don't accidentally erase an explicit timestamp.
unit_to_entities: dict[str, set[str]] = {}
unit_event_date: dict[str, datetime | None] = {}
for unit_id, entity_id, event_date in unit_entity_pairs:
unit_to_entities.setdefault(unit_id, set()).add(entity_id)
if event_date is not None and unit_event_date.get(unit_id) is None:
unit_event_date[unit_id] = event_date
elif unit_id not in unit_event_date:
unit_event_date[unit_id] = event_date
# Build map of unit -> entities for co-occurrence calculation
# Use sets to avoid duplicate entities in the same unit
unit_to_entities = {}
for unit_id, entity_id in unit_entity_pairs:
if unit_id not in unit_to_entities:
unit_to_entities[unit_id] = set()
unit_to_entities[unit_id].add(entity_id)
# Update co-occurrences for all pairs in each unit. Carry the unit's
# event_date onto every pair so the flush step can stamp
# `last_cooccurred` with the correct time.
cooccurrence_pairs: dict[tuple[str, str], datetime | None] = {}
# Update co-occurrences for all pairs in each unit
cooccurrence_pairs = set() # Use set to avoid duplicates
for unit_id, entity_ids in unit_to_entities.items():
entity_list = list(entity_ids)
event_date = unit_event_date.get(unit_id)
entity_list = list(entity_ids) # Convert set to list for iteration
# For each pair of entities in this unit, create co-occurrence
for i, entity_id_1 in enumerate(entity_list):
for entity_id_2 in entity_list[i + 1 :]:
# Skip if same entity (shouldn't happen with set, but be safe)
if entity_id_1 == entity_id_2:
continue
# Canonical ordering (entity_id_1 < entity_id_2) matches the
# entity_cooccurrences PK and check constraint.
# Ensure consistent ordering (entity_id_1 < entity_id_2)
if entity_id_1 > entity_id_2:
entity_id_1, entity_id_2 = entity_id_2, entity_id_1
key = (entity_id_1, entity_id_2)
prev = cooccurrence_pairs.get(key, _SENTINEL_MISSING)
if prev is _SENTINEL_MISSING:
cooccurrence_pairs[key] = event_date
else:
cooccurrence_pairs[key] = _later_date(prev, event_date)
cooccurrence_pairs.add((entity_id_1, entity_id_2))
# Accumulate co-occurrence pairs for post-transaction flush.
# The actual INSERT/UPDATE is deferred to flush_pending_stats() to avoid
@@ -995,8 +935,7 @@ class EntityResolver:
if cooccurrence_pairs:
key = self._task_key()
self._pending_cooccurrences.setdefault(key, []).extend(
_CooccurrencePair(entity_id_1=e1, entity_id_2=e2, event_date=ed)
for (e1, e2), ed in cooccurrence_pairs.items()
_CooccurrencePair(entity_id_1=e1, entity_id_2=e2) for e1, e2 in cooccurrence_pairs
)
async def get_units_by_entity(self, entity_id: str, limit: int = 100) -> list[str]:
@@ -129,7 +129,6 @@ _PROVIDERS_WITHOUT_API_KEY = frozenset(
"none",
"vertexai",
"litellm",
"litellmrouter",
"bedrock",
}
)
@@ -149,12 +148,10 @@ def create_llm_provider(
groq_service_tier: str | None = None,
openai_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,
vertexai_region: str | None = None,
vertexai_credentials: Any = None,
gemini_safety_settings: list | None = None,
litellmrouter_config: dict[str, Any] | None = None,
) -> Any: # Returns LLMInterface
"""
Factory function to create the appropriate LLM provider implementation.
@@ -168,9 +165,6 @@ def create_llm_provider(
groq_service_tier: Groq service tier (for Groq provider) - "on_demand", "flex", or "auto".
openai_service_tier: OpenAI service tier (for OpenAI provider) - None (default) or "flex" (50% cheaper).
extra_body: Extra body params merged into OpenAI-compatible API calls.
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).
@@ -185,7 +179,6 @@ def create_llm_provider(
CodexLLM,
GeminiLLM,
LiteLLMLLM,
LiteLLMRouterLLM,
LlamaCppLLM,
MockLLM,
NoneLLM,
@@ -250,7 +243,6 @@ def create_llm_provider(
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
default_headers=default_headers,
)
elif provider_lower == "litellm":
@@ -262,23 +254,6 @@ def create_llm_provider(
reasoning_effort=reasoning_effort,
)
elif provider_lower == "litellmrouter":
if not litellmrouter_config:
raise ValueError(
"Provider 'litellmrouter' requires a config object. "
"Set HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG (or the per-op variant) "
"to a JSON object accepted by litellm.Router. "
"See https://docs.litellm.ai/docs/routing."
)
return LiteLLMRouterLLM(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
config=litellmrouter_config,
reasoning_effort=reasoning_effort,
)
elif provider_lower == "bedrock":
# Bedrock is a first-class alias backed by LiteLLM with auto-prefixed model names
bedrock_model = model if model.startswith("bedrock/") else f"bedrock/{model}"
@@ -308,18 +283,7 @@ def create_llm_provider(
extra_args=config.llamacpp_extra_args,
)
elif provider_lower in (
"openai",
"groq",
"ollama",
"lmstudio",
"minimax",
"deepseek",
"volcano",
"openrouter",
"zai",
"opencode-go",
):
elif provider_lower in ("openai", "groq", "ollama", "lmstudio", "minimax", "deepseek", "volcano", "openrouter"):
return OpenAICompatibleLLM(
provider=provider,
api_key=api_key,
@@ -353,8 +317,6 @@ class LLMProvider:
openai_service_tier: str | None = None,
gemini_safety_settings: list | None = None,
extra_body: dict[str, Any] | None = None,
default_headers: dict[str, str] | None = None,
litellmrouter_config: dict[str, Any] | None = None,
):
"""
Initialize LLM provider.
@@ -369,22 +331,12 @@ class LLMProvider:
openai_service_tier: OpenAI service tier (None or "flex") - from config.
gemini_safety_settings: Safety settings for Gemini/VertexAI providers.
extra_body: Extra body params merged into OpenAI-compatible API calls.
default_headers: Custom headers passed as ``default_headers`` to provider SDK clients.
Used by operators routing through proxies / request-tracing middleware. Falls
back to ``HindsightConfig.llm_default_headers`` (env: ``HINDSIGHT_API_LLM_DEFAULT_HEADERS``)
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"``.
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
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
@@ -392,17 +344,6 @@ class LLMProvider:
self.gemini_safety_settings = gemini_safety_settings
# 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).
# 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 = [
@@ -421,12 +362,9 @@ class LLMProvider:
"minimax",
"deepseek",
"litellm",
"litellmrouter",
"bedrock",
"volcano",
"openrouter",
"zai",
"opencode-go",
]
if self.provider not in valid_providers:
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
@@ -445,10 +383,6 @@ class LLMProvider:
self.base_url = "https://api.deepseek.com"
elif self.provider == "openrouter":
self.base_url = "https://openrouter.ai/api/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"
# Prepare Vertex AI config (if applicable)
vertexai_project_id = None
@@ -504,19 +438,6 @@ class LLMProvider:
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(
provider=self.provider,
@@ -527,12 +448,10 @@ class LLMProvider:
groq_service_tier=self.groq_service_tier,
openai_service_tier=self.openai_service_tier,
extra_body=self.extra_body,
default_headers=self.default_headers,
vertexai_project_id=vertexai_project_id,
vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials,
gemini_safety_settings=self.gemini_safety_settings,
litellmrouter_config=router_config,
)
# Backward compatibility: Keep mock provider properties
@@ -840,14 +759,13 @@ class LLMProvider:
def from_env(cls) -> "LLMProvider":
"""Create provider from environment variables using config.py constants."""
from ..config import (
DEFAULT_LLM_MODEL,
DEFAULT_LLM_PROVIDER,
ENV_LLM_API_KEY,
ENV_LLM_BASE_URL,
ENV_LLM_DEFAULT_HEADERS,
ENV_LLM_EXTRA_BODY,
ENV_LLM_MODEL,
ENV_LLM_PROVIDER,
_get_default_model_for_provider,
)
provider = os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER)
@@ -861,9 +779,8 @@ class LLMProvider:
)
base_url = os.getenv(ENV_LLM_BASE_URL, "")
model = os.getenv(ENV_LLM_MODEL) or _get_default_model_for_provider(provider)
model = os.getenv(ENV_LLM_MODEL, DEFAULT_LLM_MODEL)
extra_body = json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null"))
default_headers = json.loads(os.getenv(ENV_LLM_DEFAULT_HEADERS, "null"))
return cls(
provider=provider,
@@ -872,7 +789,6 @@ class LLMProvider:
model=model,
reasoning_effort="low",
extra_body=extra_body,
default_headers=default_headers,
)
@@ -18,7 +18,7 @@ import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Literal, cast, overload
from typing import TYPE_CHECKING, Any, Literal, overload
import asyncpg
import httpx
@@ -502,15 +502,9 @@ class MemoryEngine(MemoryEngineInterface):
self._dialect: SQLDialect | None = None
# Connection pool — set from backend.get_pool() for backward compatibility
self._pool = None
self._read_backend: DatabaseBackend | None = None
self._read_database_url: str | None = (
config.read_database_url if self._database_backend_type == "postgresql" else None
)
self._initialized = False
self._pool_min_size = pool_min_size if pool_min_size is not None else config.db_pool_min_size
self._pool_max_size = pool_max_size if pool_max_size is not None else config.db_pool_max_size
self._read_pool_min_size = config.read_db_pool_min_size
self._read_pool_max_size = config.read_db_pool_max_size
self._db_command_timeout = db_command_timeout if db_command_timeout is not None else config.db_command_timeout
self._db_acquire_timeout = db_acquire_timeout if db_acquire_timeout is not None else config.db_acquire_timeout
self._db_statement_timeout = config.db_statement_timeout
@@ -545,8 +539,6 @@ class MemoryEngine(MemoryEngineInterface):
base_url=memory_llm_base_url,
model=memory_llm_model,
extra_body=config.llm_extra_body,
default_headers=config.llm_default_headers,
litellmrouter_config=config.llm_litellmrouter_config,
)
# Store client and model for convenience (deprecated: use _llm_config.call() instead)
@@ -574,8 +566,6 @@ class MemoryEngine(MemoryEngineInterface):
base_url=retain_base_url,
model=retain_model,
extra_body=config.llm_extra_body,
default_headers=config.llm_default_headers,
litellmrouter_config=config.retain_llm_litellmrouter_config or config.llm_litellmrouter_config,
)
# Reflect LLM config - for think/observe operations (can use lighter models)
@@ -598,8 +588,6 @@ class MemoryEngine(MemoryEngineInterface):
base_url=reflect_base_url,
model=reflect_model,
extra_body=config.llm_extra_body,
default_headers=config.llm_default_headers,
litellmrouter_config=config.reflect_llm_litellmrouter_config or config.llm_litellmrouter_config,
)
# Consolidation LLM config - for mental model consolidation (can use efficient models)
@@ -622,8 +610,6 @@ class MemoryEngine(MemoryEngineInterface):
base_url=consolidation_base_url,
model=consolidation_model,
extra_body=config.llm_extra_body,
default_headers=config.llm_default_headers,
litellmrouter_config=config.consolidation_llm_litellmrouter_config or config.llm_litellmrouter_config,
)
# Initialize cross-encoder reranker (cached for performance)
@@ -1658,16 +1644,11 @@ class MemoryEngine(MemoryEngineInterface):
# Parent doesn't exist (shouldn't happen)
return
# Get all sibling operations (including this one).
# This query runs in the same transaction, so it sees the current
# child's updated status. Pull error_message too so a parent that
# fails can inherit a representative child reason -- otherwise
# downstream consumers (dashboards, alert filters) lose the actual
# cause once a batch has children. See the worker poller's
# _summarise_child_error_messages for the propagation rationale.
# Get all sibling operations (including this one)
# This query runs in the same transaction, so it sees the current child's updated status
siblings = await conn.fetch(
f"""
SELECT status, error_message
SELECT status
FROM {fq_table("async_operations")}
WHERE bank_id = $1
AND result_metadata::jsonb @> $2::jsonb
@@ -1691,12 +1672,7 @@ class MemoryEngine(MemoryEngineInterface):
# All siblings are done - update parent status
if any_failed:
new_status = "failed"
# Set parent error message to indicate child failure. Inherit
# the most-common failed-child error_message rather than a
# generic string so downstream filters can attribute the
# cause correctly.
from hindsight_api.worker.poller import _summarise_child_error_messages
# Set parent error message to indicate child failure
await conn.execute(
f"""
UPDATE {fq_table("async_operations")}
@@ -1705,7 +1681,7 @@ class MemoryEngine(MemoryEngineInterface):
""",
uuid.UUID(parent_operation_id),
new_status,
_summarise_child_error_messages(siblings),
"One or more sub-batches failed",
)
elif all_completed:
new_status = "completed"
@@ -1949,23 +1925,6 @@ class MemoryEngine(MemoryEngineInterface):
# These will be migrated to use self._backend.acquire() over time.
self._pool = self._backend.get_pool()
if self._read_database_url:
logger.info(
f"Opening read backend against {mask_network_location(self._read_database_url)} for recall queries"
)
self._read_backend = create_database_backend(self._database_backend_type)
await self._read_backend.initialize(
self._read_database_url,
min_size=self._read_pool_min_size,
max_size=self._read_pool_max_size,
command_timeout=self._db_command_timeout,
acquire_timeout=self._db_acquire_timeout,
statement_cache_size=0,
init_callback=_init_connection,
)
else:
self._read_backend = self._backend
# Initialize entity resolver with pool and configured lookup strategy
self.entity_resolver = EntityResolver(
self._backend,
@@ -2054,15 +2013,6 @@ class MemoryEngine(MemoryEngineInterface):
await self.initialize()
return self._pool
async def _get_read_backend(self) -> DatabaseBackend:
"""Get the read-only backend (replica when configured, otherwise primary).
Writes MUST NOT be issued through this backend.
"""
if not self._initialized:
await self.initialize()
return self._read_backend
async def _get_backend(self) -> DatabaseBackend:
"""Get the database backend, auto-initializing if needed."""
if not self._initialized:
@@ -2119,11 +2069,7 @@ class MemoryEngine(MemoryEngineInterface):
await self._http_client.aclose()
self._http_client = None
if self._read_backend is not None and self._read_backend is not self._backend:
await self._read_backend.shutdown()
self._read_backend = None
# Close primary database backend (shuts down pool)
# Close database backend (shuts down pool)
if self._backend is not None:
await self._backend.shutdown()
self._backend = None
@@ -2348,12 +2294,6 @@ class MemoryEngine(MemoryEngineInterface):
if result and result.contents is not None:
contents = result.contents
# Engine-owned copy: the orchestrator clears per-item "content" strings
# after building the document's combined text (memory pressure
# optimization, see retain/orchestrator.py). Without an internal copy
# those mutations leak back to the caller's dicts.
contents = cast(list[RetainContentDict], [dict(c) for c in contents])
# Apply batch-level document_id to contents that don't have their own (backwards compatibility)
if document_id:
for item in contents:
@@ -2974,7 +2914,7 @@ class MemoryEngine(MemoryEngineInterface):
if tracer:
tracer.start()
backend = await self._get_read_backend()
backend = await self._get_backend()
recall_start = time.time()
# Buffer logs for clean output in concurrent scenarios.
@@ -3043,7 +2983,7 @@ class MemoryEngine(MemoryEngineInterface):
max_connections=effective_connection_budget,
operation_id=f"recall-{recall_id}",
) as op:
budgeted_pool = op.wrap_pool(backend)
budgeted_pool = op.wrap_pool(self._backend)
parallel_start = time.time()
multi_result = await retrieve_all_fact_types_parallel(
budgeted_pool,
@@ -3632,21 +3572,26 @@ class MemoryEngine(MemoryEngineInterface):
source_facts_dict[sid] = _make_source_fact(sid, r)
total_source_tokens += fact_tokens
# Get entities for each fact if include_entities is requested.
# _entity_rows_for_units_sql resolves both direct unit_entities rows
# and observation-via-source-memory inheritance in a single query.
fact_entity_map = {} # unit_id -> list of {entity_id, canonical_name}
# Get entities for each fact if include_entities is requested
fact_entity_map = {} # unit_id -> list of (entity_id, entity_name)
if include_entities and top_scored:
unit_ids = [uuid.UUID(sr.id) for sr in top_scored]
if unit_ids:
async with acquire_with_retry(backend) as entity_conn:
entity_rows = await entity_conn.fetch(
self._entity_rows_for_units_sql(unit_ids_placeholder=1),
f"""
SELECT ue.unit_id, e.id as entity_id, e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON ue.entity_id = e.id
WHERE ue.unit_id = ANY($1::uuid[])
""",
unit_ids,
)
for row in entity_rows:
unit_id = str(row["unit_id"])
fact_entity_map.setdefault(unit_id, []).append(
if unit_id not in fact_entity_map:
fact_entity_map[unit_id] = []
fact_entity_map[unit_id].append(
{"entity_id": str(row["entity_id"]), "canonical_name": row["canonical_name"]}
)
@@ -3736,67 +3681,12 @@ class MemoryEngine(MemoryEngineInterface):
)
except Exception as e:
# Use repr(e) so exceptions with empty __str__ (e.g. raise SomeError())
# still emit a discriminating class+args string into operations.error_message.
log_buffer.append(
f"[RECALL {recall_id}] ERROR after {time.time() - recall_start:.3f}s: {type(e).__name__}: {e!r}"
f"[RECALL {recall_id}] ERROR after {time.time() - recall_start:.3f}s: {type(e).__name__}: {e}"
)
if not quiet:
logger.error("\n" + "\n".join(log_buffer), exc_info=True)
raise RuntimeError(f"Failed to search memories ({type(e).__name__}): {e!r}") from e
def _entity_rows_for_units_sql(self, unit_ids_placeholder: int) -> str:
"""SQL SELECT producing ``(unit_id, entity_id, canonical_name)`` rows for
the given unit IDs.
Direct rows come from ``unit_entities``. Observations rarely carry
direct rows there; their entity association lives transitively through
their source memories (``source_memory_ids`` on PG, the
``observation_sources`` junction on Oracle). When an observation has
no direct entity rows the SELECT inherits its source memories'
entities, so the result is the same set callers would get from
``get_memory_unit``.
``unit_ids_placeholder`` is the 1-based parameter index that holds the
``uuid[]`` of unit IDs. The placeholder is referenced twice both
sides of the UNION need it so callers should not reuse the slot.
"""
ue = fq_table("unit_entities")
ents = fq_table("entities")
mu = fq_table("memory_units")
p = unit_ids_placeholder
direct = (
f"SELECT ue.unit_id, e.id AS entity_id, e.canonical_name "
f"FROM {ue} ue "
f"JOIN {ents} e ON e.id = ue.entity_id "
f"WHERE ue.unit_id = ANY(${p}::uuid[])"
)
if self._backend.ops.uses_observation_sources_table:
os_t = fq_table("observation_sources")
inherited = (
f"SELECT os.observation_id AS unit_id, e.id AS entity_id, e.canonical_name "
f"FROM {os_t} os "
f"JOIN {ue} src_ue ON src_ue.unit_id = os.source_id "
f"JOIN {ents} e ON e.id = src_ue.entity_id "
f"WHERE os.observation_id = ANY(${p}::uuid[]) "
f"AND NOT EXISTS (SELECT 1 FROM {ue} d WHERE d.unit_id = os.observation_id)"
)
else:
inherited = (
f"SELECT obs.id AS unit_id, e.id AS entity_id, e.canonical_name "
f"FROM {mu} obs "
f"CROSS JOIN LATERAL unnest(obs.source_memory_ids) AS src_id "
f"JOIN {ue} src_ue ON src_ue.unit_id = src_id "
f"JOIN {ents} e ON e.id = src_ue.entity_id "
f"WHERE obs.id = ANY(${p}::uuid[]) "
f"AND obs.fact_type = 'observation' "
f"AND obs.source_memory_ids IS NOT NULL "
f"AND NOT EXISTS (SELECT 1 FROM {ue} d WHERE d.unit_id = obs.id)"
)
return f"({direct}) UNION ({inherited})"
logger.error("\n" + "\n".join(log_buffer))
raise Exception(f"Failed to search memories: {type(e).__name__}: {e}")
def _filter_by_token_budget(
self, results: list[dict[str, Any]], max_tokens: int
@@ -5196,15 +5086,31 @@ class MemoryEngine(MemoryEngineInterface):
if not row:
return None
# Get entity information. _entity_rows_for_units_sql handles the
# observation→source_memory_ids inheritance fallback in SQL, so a
# single query covers direct rows and inherited ones.
# Get entity information
entities_rows = await conn.fetch(
self._entity_rows_for_units_sql(unit_ids_placeholder=1),
[row["id"]],
f"""
SELECT e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON ue.entity_id = e.id
WHERE ue.unit_id = $1
""",
row["id"],
)
entities = [r["canonical_name"] for r in entities_rows]
# For observations with no direct entities, inherit from source memories
if not entities and row["fact_type"] == "observation" and row["source_memory_ids"]:
source_entities_rows = await conn.fetch(
f"""
SELECT DISTINCT e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON ue.entity_id = e.id
WHERE ue.unit_id = ANY($1::uuid[])
""",
row["source_memory_ids"],
)
entities = [r["canonical_name"] for r in source_entities_rows]
result = {
"id": str(row["id"]),
"text": row["text"],
@@ -9,7 +9,6 @@ from .claude_code_llm import ClaudeCodeLLM
from .codex_llm import CodexLLM
from .gemini_llm import GeminiLLM
from .litellm_llm import LiteLLMLLM
from .litellm_router_llm import LiteLLMRouterLLM
from .llamacpp_llm import LlamaCppLLM
from .mock_llm import MockLLM
from .none_llm import NoneLLM
@@ -22,7 +21,6 @@ __all__ = [
"GeminiLLM",
"LlamaCppLLM",
"LiteLLMLLM",
"LiteLLMRouterLLM",
"MockLLM",
"NoneLLM",
"OpenAICompatibleLLM",
@@ -37,7 +37,6 @@ class AnthropicLLM(LLMInterface):
model: str,
reasoning_effort: str = "low",
timeout: float = 300.0,
default_headers: dict[str, str] | None = None,
**kwargs: Any,
):
"""
@@ -50,10 +49,6 @@ class AnthropicLLM(LLMInterface):
model: Model name (e.g., "claude-sonnet-4-20250514").
reasoning_effort: Reasoning effort level (not used by Anthropic).
timeout: Request timeout in seconds.
default_headers: Optional custom headers passed as ``default_headers`` to
the Anthropic SDK client. Used by operators routing through proxies
or request-tracing middleware. Sourced from ``llm_default_headers`` in
``HindsightConfig`` (env: ``HINDSIGHT_API_LLM_DEFAULT_HEADERS``).
**kwargs: Additional provider-specific parameters.
"""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
@@ -65,16 +60,11 @@ class AnthropicLLM(LLMInterface):
try:
from anthropic import AsyncAnthropic
# SDK retries disabled — wrapper-level retry loop in ``call`` handles
# backoff (mirrors ``OpenAICompatibleLLM`` so the two providers behave
# consistently).
client_kwargs: dict[str, Any] = {"api_key": self.api_key, "max_retries": 0}
client_kwargs: dict[str, Any] = {"api_key": self.api_key}
if self.base_url:
client_kwargs["base_url"] = self.base_url
if timeout:
client_kwargs["timeout"] = timeout
if default_headers:
client_kwargs["default_headers"] = default_headers
self._client = AsyncAnthropic(**client_kwargs)
logger.info(f"Anthropic client initialized for model: {self.model}")
@@ -12,8 +12,6 @@ import logging
import time
from typing import Any
from pydantic import ValidationError
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
@@ -280,12 +278,6 @@ class ClaudeCodeLLM(LLMInterface):
return result
except ValidationError:
# Pydantic schema validation failure — retrying with the same
# input won't produce a different schema. Raise immediately
# instead of burning quota on identical calls (#1412).
raise
except Exception as e:
last_exception = e
@@ -4,26 +4,14 @@ 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
~/.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
``POST https://auth.openai.com/oauth/token`` ~60s before expiry. It also
reactively refreshes once on a 401/403 from the Codex backend before giving
up. The refresh request shape mirrors the canonical ``@openai/codex`` CLI
implementation (codex-rs/login/src/auth/manager.rs on github.com/openai/codex)
so that future server-side changes affect both clients identically.
"""
import asyncio
import base64
import binascii
import json
import logging
import os
import tempfile
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -36,36 +24,6 @@ from hindsight_api.metrics import get_metrics_collector
logger = logging.getLogger(__name__)
# OAuth refresh endpoint and client id, mirrored from the canonical
# ``@openai/codex`` CLI (codex-rs/login/src/auth/manager.rs on
# github.com/openai/codex). The endpoint is overridable via env var so that
# future Codex changes or staging environments can be pointed at without a
# code change — same env var name the upstream CLI uses.
_CODEX_REFRESH_TOKEN_URL = os.environ.get("CODEX_REFRESH_TOKEN_URL_OVERRIDE", "https://auth.openai.com/oauth/token")
_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
# Proactively refresh this many seconds before the JWT ``exp`` claim. The
# upstream Codex CLI uses no skew (it refreshes at ``exp <= now``); the
# extra window reduces races where a request leaves the client with a token
# that the server has already declared expired by the time it arrives.
_CODEX_TOKEN_REFRESH_SKEW_SECONDS = 60
# OAuth error codes that the refresh endpoint returns when the refresh_token
# itself is no longer usable. These are terminal — retrying refresh will not
# succeed; the user must re-run ``codex auth login``.
_CODEX_TERMINAL_REFRESH_ERROR_CODES = frozenset(
{"refresh_token_expired", "refresh_token_reused", "refresh_token_invalidated"}
)
class CodexRefreshExpiredError(RuntimeError):
"""Raised when the Codex refresh_token itself is no longer valid.
The user must re-run ``codex auth login`` to obtain new credentials.
Callers should surface a clear remediation message and stop retrying.
"""
class CodexLLM(LLMInterface):
"""
LLM provider using OpenAI Codex OAuth authentication.
@@ -86,19 +44,9 @@ class CodexLLM(LLMInterface):
"""Initialize Codex LLM provider."""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
# Path is fixed at ~/.codex/auth.json — matches the upstream CLI.
# Storing it on self lets the refresh path re-read after another
# process (e.g. a sidecar) rotates the file out from under us.
self._auth_file = Path.home() / ".codex" / "auth.json"
# Single-flight refresh lock. Multiple concurrent requests racing
# toward an expired token should produce one network refresh, not N.
self._auth_lock = asyncio.Lock()
# Load Codex OAuth credentials
try:
self.access_token, self.account_id = self._load_codex_auth()
self.refresh_token = self._load_codex_refresh_token()
logger.info(f"Loaded Codex OAuth credentials for account: {self.account_id}")
except Exception as e:
raise RuntimeError(
@@ -160,290 +108,6 @@ class CodexLLM(LLMInterface):
return access_token, account_id
def _load_codex_refresh_token(self) -> str | None:
"""Load ``tokens.refresh_token`` from ``~/.codex/auth.json``.
Returns None when the auth file is unreadable or omits the field —
the provider still functions as a one-shot loader in that case, it
just can't refresh when the access_token expires. This deliberately
does not raise so that ``__init__`` keeps the existing failure mode
of raising only on missing ``access_token``.
"""
try:
with open(self._auth_file) as f:
data = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.warning(
f"Codex auth file unreadable when loading refresh_token: {type(e).__name__}. "
"Token refresh will not be available; the access_token in memory will be used until it expires."
)
return None
return data.get("tokens", {}).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 parse failure.
ChatGPT/Codex access_tokens are JWTs whose payload includes ``exp``
(RFC 7519). We need the expiry to schedule proactive refresh — the
``auth.json`` file does not persist a separate ``expires_at`` field
in the upstream CLI's shape, so decoding the JWT itself is the
canonical way to know when the token is stale.
We do not verify the signature — the server is the source of truth
on whether the token is actually accepted, and the only thing this
method affects is the *timing* of refresh, not whether to trust the
token contents.
"""
try:
parts = token.split(".")
if len(parts) < 2:
return None
payload_b64 = parts[1]
# JWT uses base64url without padding. Re-pad before decoding.
padding = "=" * (-len(payload_b64) % 4)
payload_bytes = base64.urlsafe_b64decode(payload_b64 + padding)
payload = json.loads(payload_bytes.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 = _CODEX_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`` parsing fails.
"""
exp = self._decode_jwt_exp_unixtime(self.access_token)
if exp is None:
return False
return exp <= int(time.time()) + skew_seconds
def _persist_auth_atomic(self, updated_tokens: dict[str, Any]) -> None:
"""Write the rotated tokens back to ``~/.codex/auth.json`` atomically.
Strategy: re-read the on-disk auth.json (so we don't clobber fields
another process may have added), patch ``tokens.*`` and
``last_refresh``, write to a tempfile in the same directory with
mode 0600, then ``os.replace`` onto the target. ``os.replace`` is
atomic within the same filesystem on POSIX and Windows, so a
concurrent reader will see either the old file or the fully-written
new file — never a partial truncate, which is the upstream CLI's
worst-case race.
On non-Unix platforms the chmod is a best-effort no-op; the parent
directory permissions still bound access.
"""
current: dict[str, Any]
try:
with open(self._auth_file) as f:
loaded = json.load(f)
# auth.json should always be a JSON object at the top level; if
# someone has hand-edited it into a non-object shape, fall back
# to the minimal default rather than crashing the refresh path.
current = loaded if isinstance(loaded, dict) else {"auth_mode": "chatgpt", "tokens": {}}
except (OSError, json.JSONDecodeError):
# If the file became unreadable between our last read and now,
# construct a minimal shape rather than refusing to persist.
current = {"auth_mode": "chatgpt", "tokens": {}}
existing_tokens = current.get("tokens")
tokens: dict[str, Any] = existing_tokens if isinstance(existing_tokens, dict) else {}
for key in ("access_token", "refresh_token", "id_token", "account_id"):
if key in updated_tokens and updated_tokens[key] is not None:
tokens[key] = updated_tokens[key]
current["tokens"] = tokens
current["last_refresh"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
# Write to a sibling tempfile so the rename is same-filesystem.
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())
try:
os.chmod(tmp_path, 0o600)
except OSError:
pass # best-effort on platforms that don't support chmod
os.replace(tmp_path, self._auth_file)
except Exception:
# Clean up the orphaned tempfile if rename fails.
try:
os.unlink(tmp_path)
except OSError:
pass
raise
async def _refresh_oauth_tokens(self, reason: str = "", *, force: bool = False) -> None:
"""Refresh the OAuth access_token using the stored refresh_token.
Single-flight: serialized through ``self._auth_lock`` so concurrent
callers produce one network request. The first caller refreshes; the
rest wake up and observe that either (a) the in-memory token is no
longer stale (proactive case) or (b) the in-memory token has changed
since they entered (reactive case), and return without re-refreshing.
Args:
reason: Free-form string included in log lines for diagnostics.
force: When True, refresh even if the JWT exp claim looks fresh.
Used by the reactive 401 path — the server rejected the
token, so we cannot trust the JWT's self-reported expiry.
Raises:
CodexRefreshExpiredError: when the server returns a terminal
error code (refresh_token_expired/reused/invalidated) or any
401 on the refresh endpoint itself.
RuntimeError: for other refresh failures (network, 5xx, etc.).
"""
# Capture the token we'd be refreshing BEFORE acquiring the lock so
# that we can detect mid-wait rotation by another coroutine.
token_before_lock = self.access_token
async with self._auth_lock:
if force:
# Reactive: skip only if another coroutine already rotated
# the token while we were waiting on the lock.
if self.access_token != token_before_lock:
return
else:
# Proactive: skip if the token is no longer stale (the
# canonical "another coroutine refreshed first" check).
if not self._token_is_stale():
return
if not self.refresh_token:
raise RuntimeError(
"Codex access_token is expired but no refresh_token is available. "
"Run 'codex auth login' to re-authenticate."
)
log_reason = f" ({reason})" if reason else ""
logger.info(f"Refreshing Codex OAuth access_token{log_reason}")
request_body = {
"client_id": _CODEX_CLIENT_ID,
"grant_type": "refresh_token",
"refresh_token": self.refresh_token,
}
try:
response = await self._client.post(
_CODEX_REFRESH_TOKEN_URL,
json=request_body,
headers={"Content-Type": "application/json"},
timeout=30.0,
)
except httpx.RequestError as e:
raise RuntimeError(f"Codex OAuth refresh network error: {type(e).__name__}") from e
if response.status_code == 401:
# Classify by ``error.code`` (or top-level ``error`` string) — same
# mapping as the upstream Rust CLI's request_chatgpt_token_refresh.
error_code = self._extract_oauth_error_code(response)
if error_code in _CODEX_TERMINAL_REFRESH_ERROR_CODES:
raise CodexRefreshExpiredError(
f"Codex refresh_token is permanently invalid (error.code={error_code}). "
"Run 'codex auth login' to re-authenticate."
)
# Unknown 401 — treat as terminal too, matching the upstream classification.
raise CodexRefreshExpiredError(
f"Codex OAuth refresh returned 401 with unrecognized error code "
f"({error_code or 'none'}). Run 'codex auth login' to re-authenticate."
)
if response.status_code >= 400:
# 5xx and other 4xx are transient/retryable from the caller's
# perspective; surface as RuntimeError without leaking the
# request body in logs.
raise RuntimeError(f"Codex OAuth refresh failed with HTTP {response.status_code}")
try:
body = response.json()
except json.JSONDecodeError as e:
raise RuntimeError(f"Codex OAuth refresh returned non-JSON body: {e}") from e
new_access = body.get("access_token")
if not new_access:
raise RuntimeError("Codex OAuth refresh returned no access_token")
# The refresh_token may rotate on each refresh — adopt the new
# one if the server sent it, otherwise keep the existing.
new_refresh = body.get("refresh_token") or self.refresh_token
new_id_token = body.get("id_token")
# Update in-memory state first so callers waiting on the lock
# see fresh credentials immediately, even if disk write fails.
self.access_token = new_access
self.refresh_token = new_refresh
persisted = {
"access_token": new_access,
"refresh_token": new_refresh,
}
if new_id_token:
persisted["id_token"] = new_id_token
try:
self._persist_auth_atomic(persisted)
except OSError as e:
# In-memory creds are valid; warn but don't fail the request
# path. Future process starts will fall back to the stale
# on-disk auth.json and immediately refresh.
logger.warning(
f"Codex OAuth refresh succeeded but persisting auth.json failed: {type(e).__name__}. "
"In-memory credentials are up to date; on-disk file is stale."
)
logger.info("Codex OAuth access_token refreshed successfully")
@staticmethod
def _extract_oauth_error_code(response: "httpx.Response") -> str | None:
"""Pull the OAuth error code out of a 4xx response body, if present.
The refresh endpoint returns shapes like
``{"error": "...", "error_code": "..."}`` or
``{"error": {"code": "..."}}``. We don't fail the call if the body
is unparseable — the caller falls back to a generic "unknown" error.
"""
try:
body = response.json()
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(body, dict):
return None
# Shape 1: error is a nested object with "code"
err = body.get("error")
if isinstance(err, dict):
code = err.get("code")
if isinstance(code, str):
return code
# Shape 2: top-level error_code string
code = body.get("error_code")
if isinstance(code, str):
return code
# Shape 3: error is itself a string code
if isinstance(err, str):
return err
return None
async def _ensure_fresh_token(self) -> None:
"""Refresh the access_token proactively if it is near or past expiry.
Called at the top of every API-bound method. Cheap when the token is
fresh (just decodes the JWT exp claim and returns).
"""
if self._token_is_stale():
try:
await self._refresh_oauth_tokens(reason="proactive (token near expiry)")
except CodexRefreshExpiredError:
# Surface to the caller as the same RuntimeError shape the
# request loop has historically raised, so existing error
# handling paths keep working.
raise
def _map_reasoning_effort(self, effort: str) -> str:
"""
Map standard reasoning effort to Codex reasoning summary format.
@@ -525,15 +189,6 @@ class CodexLLM(LLMInterface):
"""Make API call to Codex backend with SSE streaming."""
start_time = time.time()
# Proactively refresh the OAuth access_token if it's near expiry.
# Cheap when fresh: a JWT exp decode + comparison.
await self._ensure_fresh_token()
# Tracks whether we've already attempted a reactive refresh in
# response to a 401 from the backend. Set once on the first auth
# failure so we retry exactly once after refresh, not in a loop.
attempted_refresh_after_auth_error = False
# Prepare system instructions
system_instruction = ""
user_messages = []
@@ -589,12 +244,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
# normal-retry budget slot. The refresh-retry is conceptually a
# separate auth-recovery attempt that shouldn't compete with backoff.
attempt = 0
while True:
for attempt in range(max_retries + 1):
try:
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
response.raise_for_status()
@@ -619,7 +269,6 @@ class CodexLLM(LLMInterface):
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
last_exception = e
attempt += 1
continue
raise
@@ -683,38 +332,8 @@ class CodexLLM(LLMInterface):
last_exception = e
status_code = e.response.status_code
# Auth error: try one OAuth refresh + retry before giving up.
# The proactive refresh at the top of this method catches most
# expiries, but a token can also become invalid mid-request if
# another process rotates auth.json out from under us, or if
# the JWT exp claim is unparseable and we never knew it was
# stale. Reactive refresh is the safety net.
# Fast fail on auth errors
if status_code in (401, 403):
if not attempted_refresh_after_auth_error:
attempted_refresh_after_auth_error = True
try:
await self._refresh_oauth_tokens(
reason=f"reactive (HTTP {status_code} from codex backend)",
force=True,
)
# Rebuild the Authorization header with the new
# token and retry without consuming a normal-retry
# budget slot — this is a dedicated auth-recovery
# attempt that shouldn't compete with backoff.
headers["Authorization"] = f"Bearer {self.access_token}"
logger.info("Codex auth refreshed after auth error; retrying request once")
continue
except CodexRefreshExpiredError as refresh_err:
logger.error("Codex refresh_token is permanently invalid; cannot recover from auth error")
raise RuntimeError(
"Codex authentication failed and the refresh_token is no longer valid.\n"
"Run 'codex auth login' to re-authenticate."
) from refresh_err
except Exception as refresh_err:
logger.error(
f"Codex token refresh attempt failed: {type(refresh_err).__name__}: {refresh_err}"
)
# Fall through to the original raise below.
logger.error(f"Codex auth error (HTTP {status_code}): {e.response.text[:200]}")
raise RuntimeError(
"Codex authentication failed. Your OAuth token may have expired.\n"
@@ -730,7 +349,6 @@ class CodexLLM(LLMInterface):
f"Codex HTTP error {status_code} (attempt {attempt + 1}/{max_retries + 1}): {error_detail}"
)
await asyncio.sleep(backoff)
attempt += 1
continue
else:
logger.error(
@@ -744,7 +362,6 @@ class CodexLLM(LLMInterface):
backoff = min(initial_backoff * (2**attempt), max_backoff)
logger.warning(f"Codex connection error (attempt {attempt + 1}/{max_retries + 1}): {e}")
await asyncio.sleep(backoff)
attempt += 1
continue
else:
logger.error(f"Codex connection error after {max_retries + 1} attempts: {e}")
@@ -845,11 +462,6 @@ class CodexLLM(LLMInterface):
"""
start_time = time.time()
# Proactively refresh the OAuth access_token if it's near expiry.
# Same rationale as in ``call()`` — keeps the request from leaving
# the client carrying a token that's already past ``exp``.
await self._ensure_fresh_token()
# Prepare system instructions
system_instruction = ""
user_messages = []
@@ -922,39 +534,9 @@ class CodexLLM(LLMInterface):
# Debug logging for troubleshooting
logger.debug(f"Codex tool call request: url={url}, model={payload['model']}, tools={len(codex_tools)}")
# One reactive refresh attempt on auth failure, mirroring call().
# ``call_with_tools`` doesn't have a retry loop, so we hand-roll a
# single retry after refreshing the token. Any non-auth error still
# surfaces immediately to keep behavior identical for callers.
attempted_refresh_after_auth_error = False
try:
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
if response.status_code in (401, 403) and not attempted_refresh_after_auth_error:
attempted_refresh_after_auth_error = True
try:
await self._refresh_oauth_tokens(
reason=f"reactive (HTTP {response.status_code} from codex backend in call_with_tools)",
force=True,
)
headers["Authorization"] = f"Bearer {self.access_token}"
logger.info("Codex auth refreshed after auth error; retrying tool-call request once")
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
except CodexRefreshExpiredError as refresh_err:
logger.error(
"Codex refresh_token is permanently invalid; cannot recover from auth error in tool-call path"
)
raise RuntimeError(
"Codex authentication failed and the refresh_token is no longer valid.\n"
"Run 'codex auth login' to re-authenticate."
) from refresh_err
except Exception as refresh_err:
logger.error(
f"Codex token refresh attempt failed in tool-call path: {type(refresh_err).__name__}: {refresh_err}"
)
# Fall through to the normal error path below.
# Log response details on error
if response.status_code != 200:
logger.error(f"Codex API error {response.status_code}: {response.text[:500]}")
@@ -103,58 +103,12 @@ class LiteLLMLLM(LLMInterface):
if self.base_url:
kwargs["api_base"] = self.base_url
if max_completion_tokens is not None:
kwargs["max_completion_tokens"] = self._cap_max_completion_tokens(max_completion_tokens)
kwargs["max_completion_tokens"] = max_completion_tokens
if temperature is not None:
kwargs["temperature"] = temperature
return kwargs
# ── per-model output-tokens cap (shared with Router subclass) ────────────
# Hindsight's defaults (e.g. retain_max_completion_tokens=64000) target
# high-capacity models. When a configured deployment supports fewer
# completion tokens (e.g. gpt-4.1-nano caps at 32768), the call would
# otherwise be rejected. Cap pre-emptively using LiteLLM's per-model
# registry so things work out of the box across the supported model set.
def _cap_max_completion_tokens(self, value: int) -> int:
cap = self._get_model_output_cap()
if cap and value > cap:
logger.debug("capping max_completion_tokens %d -> %d for model %s", value, cap, self.model)
return cap
return value
def _get_model_output_cap(self) -> int | None:
"""Return the configured model's max output tokens, per LiteLLM's registry."""
try:
cap = self._litellm.get_max_tokens(self.model)
return int(cap) if cap else None
except Exception:
return None
# ── hooks for Router-style subclasses ────────────────────────────────────
# The retry+parse loop in call() / call_with_tools() is shared by every
# LiteLLM-backed provider. Subclasses override the small surface below to
# swap the completion fn (direct vs Router) and rename the deployment that
# actually answered the request.
@property
def _stage_label(self) -> str:
"""Stage breadcrumb label — overridden by subclasses (e.g. ``litellmrouter``)."""
return "litellm"
async def _acompletion(self, **kwargs: Any) -> Any:
"""Issue a chat completion. Subclasses override to route via ``litellm.Router``."""
return await self._litellm.acompletion(**kwargs)
def _resolve_completion_model(self, response: Any) -> str:
"""
Return the model name to record in metrics/tracing.
For Router-backed providers this can differ from ``self.model`` — the Router
may pick a different deployment than the primary. Default: ``self.model``.
"""
return self.model
async def call(
self,
messages: list[dict[str, str]],
@@ -189,13 +143,12 @@ class LiteLLMLLM(LLMInterface):
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.{self._stage_label}.{scope}.attempt={attempt + 1}/{max_retries + 1}")
set_stage(f"llm.litellm.{scope}.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await self._acompletion(**call_kwargs)
response = await self._litellm.acompletion(**call_kwargs)
content = response.choices[0].message.content or ""
finish_reason = response.choices[0].finish_reason
model_name = self._resolve_completion_model(response)
# Check for length-limited output
if finish_reason == "length":
@@ -231,7 +184,7 @@ class LiteLLMLLM(LLMInterface):
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=model_name,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
@@ -245,7 +198,7 @@ class LiteLLMLLM(LLMInterface):
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=model_name,
model=self.model,
scope=scope,
messages=messages,
response_content=_serialize_for_span(result),
@@ -258,7 +211,7 @@ class LiteLLMLLM(LLMInterface):
if duration > 10.0:
logger.info(
f"slow llm call: scope={scope}, model={self.provider}/{model_name}, "
f"slow llm call: scope={scope}, model={self.provider}/{self.model}, "
f"input_tokens={input_tokens}, output_tokens={output_tokens}, "
f"time={duration:.3f}s"
)
@@ -334,14 +287,13 @@ class LiteLLMLLM(LLMInterface):
last_exception = None
for attempt in range(max_retries + 1):
if attempt > 0:
set_stage(f"llm.{self._stage_label}.tools.attempt={attempt + 1}/{max_retries + 1}")
set_stage(f"llm.litellm.tools.attempt={attempt + 1}/{max_retries + 1}")
try:
response = await self._acompletion(**call_kwargs)
response = await self._litellm.acompletion(**call_kwargs)
message = response.choices[0].message
content = message.content
finish_reason = response.choices[0].finish_reason
model_name = self._resolve_completion_model(response)
# Extract tool calls
tool_calls: list[LLMToolCall] = []
@@ -367,7 +319,7 @@ class LiteLLMLLM(LLMInterface):
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=model_name,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
@@ -386,7 +338,7 @@ class LiteLLMLLM(LLMInterface):
)
span_recorder.record_llm_call(
provider=self.provider,
model=model_name,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
@@ -1,167 +0,0 @@
"""
LiteLLM Router LLM provider — pure pass-through to ``litellm.Router``.
The full configuration object is forwarded verbatim. We do not translate model
names, infer fallbacks, validate shape, or introspect Router internals:
whatever the user puts in ``HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG`` becomes
``Router(**config)``. If the shape is wrong, LiteLLM Router raises.
The only Hindsight-imposed convention is that one entry in ``model_list``
must have ``model_name: "default"`` — that's the entrypoint we issue
completions against. Everything else (ordering, fallbacks, load-balancing,
weighted picks, rate limits, retries, cooldowns) is whatever the user
configures via LiteLLM's own keys.
See https://docs.litellm.ai/docs/routing for the supported keys (``model_list``,
``fallbacks``, ``context_window_fallbacks``, ``num_retries``, ``cooldown_time``,
``routing_strategy``, ``allowed_fails``, …).
The retry/parse/metrics loop is shared with ``LiteLLMLLM`` via inheritance:
this class only overrides the completion fn, the call kwargs, and the model
name reported in metrics.
Example ``HINDSIGHT_API_LLM_LITELLMROUTER_CONFIG``::
{
"model_list": [
{"model_name": "default", "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-..."}},
{"model_name": "fallback", "litellm_params": {"model": "anthropic/claude-sonnet-4", "api_key": "sk-ant-..."}}
],
"fallbacks": [{"default": ["fallback"]}],
"num_retries": 0,
"cooldown_time": 60
}
"""
import logging
from typing import Any
from hindsight_api.engine.providers.litellm_llm import LiteLLMLLM
logger = logging.getLogger(__name__)
# Hindsight always issues completions against this ``model_name``. Users must
# include at least one entry with ``model_name: "default"`` in their config's
# ``model_list``; that entry is the entrypoint, and any other entries become
# fallback / load-balance / weighted-pool members per the user's own
# ``fallbacks`` / ``routing_strategy`` settings.
_ENTRYPOINT_MODEL_NAME = "default"
class LiteLLMRouterLLM(LiteLLMLLM):
"""
LLM provider backed by ``litellm.Router``.
The full Router config is supplied by the caller. We pass it verbatim to
``Router(**config)`` and route requests against the first ``model_list``
entry's ``model_name``. Inherits the retry/parse/metrics loop from
``LiteLLMLLM``; only the completion fn and the call kwargs differ.
"""
def __init__(
self,
provider: str,
api_key: str,
base_url: str,
model: str,
config: dict[str, Any],
reasoning_effort: str = "low",
timeout: float = 300.0,
**kwargs: Any,
):
super().__init__(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
timeout=timeout,
**kwargs,
)
self.config = config
from litellm import Router
logging.getLogger("LiteLLM Router").setLevel(logging.WARNING)
# Pure pass-through: whatever the user gave goes straight to LiteLLM Router.
# If the shape is invalid, Router raises its own error — we don't pre-validate
# or introspect Router internals.
self._router = Router(**config)
# Pre-compute the most conservative output-tokens cap across every configured
# deployment so a single max_completion_tokens value works no matter which
# deployment Router picks. Uses LiteLLM's own per-model registry; unknown
# models contribute no cap. See LiteLLMLLM._cap_max_completion_tokens.
self._router_output_cap = self._compute_router_output_cap(config)
logger.info("LiteLLM Router initialized; entrypoint model_name=%r", _ENTRYPOINT_MODEL_NAME)
def _compute_router_output_cap(self, config: dict[str, Any]) -> int | None:
caps: list[int] = []
for deployment in (config.get("model_list") or []) if isinstance(config, dict) else []:
if not isinstance(deployment, dict):
continue
params = deployment.get("litellm_params") or {}
model_str = params.get("model") if isinstance(params, dict) else None
if not model_str:
continue
try:
cap = self._litellm.get_max_tokens(model_str)
except Exception:
cap = None
if cap:
caps.append(int(cap))
return min(caps) if caps else None
# ── overrides for the shared retry/parse loop ───────────────────────────
@property
def _stage_label(self) -> str:
return "litellmrouter"
async def _acompletion(self, **kwargs: Any) -> Any:
return await self._router.acompletion(**kwargs)
def _resolve_completion_model(self, response: Any) -> str:
hidden = getattr(response, "_hidden_params", None) or {}
return hidden.get("model") or _ENTRYPOINT_MODEL_NAME
def _get_model_output_cap(self) -> int | None:
return self._router_output_cap
def _build_common_kwargs(
self,
messages: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
) -> dict[str, Any]:
# Always issue against the entrypoint group; Router handles deployment selection,
# cross-group fallbacks, retries, cooldowns — whatever the user configured.
kwargs: dict[str, Any] = {
"model": _ENTRYPOINT_MODEL_NAME,
"messages": messages,
}
if max_completion_tokens is not None:
kwargs["max_completion_tokens"] = self._cap_max_completion_tokens(max_completion_tokens)
if temperature is not None:
kwargs["temperature"] = temperature
return kwargs
async def verify_connection(self) -> None:
from hindsight_api.engine.llm_interface import OutputTooLongError
try:
await self.call(
messages=[{"role": "user", "content": "test"}],
max_completion_tokens=50,
temperature=0.0,
scope="verification",
max_retries=0,
)
logger.info("LiteLLM Router connection verified successfully")
except OutputTooLongError:
logger.info("LiteLLM Router connection verified successfully (response truncated)")
except Exception as e:
logger.error(f"LiteLLM Router connection verification failed: {e}")
raise RuntimeError(f"Failed to verify LiteLLM Router connection: {e}") from e
@@ -1,6 +1,5 @@
"""
OpenAI-compatible LLM provider supporting OpenAI, Groq, Ollama, LMStudio, MiniMax, DeepSeek,
and Opencode Go.
OpenAI-compatible LLM provider supporting OpenAI, Groq, Ollama, LMStudio, MiniMax, and DeepSeek.
This provider handles all OpenAI API-compatible models including:
- OpenAI: GPT-4, GPT-4o, GPT-5, o1, o3 (reasoning models)
@@ -9,7 +8,6 @@ This provider handles all OpenAI API-compatible models including:
- LMStudio: Local models with OpenAI-compatible API
- MiniMax: MiniMax-M2.7 models with 1M context window
- DeepSeek: deepseek-v4-flash / deepseek-v4-pro / deepseek-chat / deepseek-reasoner via api.deepseek.com
- Opencode Go: deepseek-v4-flash via https://opencode.ai/zen/go/v1
Features:
- Reasoning models with extended thinking (o1, o3, GPT-5 families)
@@ -234,7 +232,6 @@ class OpenAICompatibleLLM(LLMInterface):
- LMStudio: Local models with OpenAI-compatible API
- MiniMax: MiniMax-M2.7 models via OpenAI-compatible API (https://api.minimax.io/v1)
- DeepSeek: deepseek-v4-flash / deepseek-v4-pro / deepseek-chat / deepseek-reasoner via https://api.deepseek.com
- opencode-go: deepseek-v4-flash via https://opencode.ai/zen/go/v1
"""
def __init__(
@@ -253,7 +250,7 @@ class OpenAICompatibleLLM(LLMInterface):
Initialize OpenAI-compatible LLM provider.
Args:
provider: Provider name ("openai", "groq", "ollama", "lmstudio", "opencode-go", etc.).
provider: Provider name ("openai", "groq", "ollama", "lmstudio").
api_key: API key (optional for ollama/lmstudio).
base_url: Base URL for the API (uses defaults for groq/ollama/lmstudio if empty).
model: Model name.
@@ -276,8 +273,6 @@ class OpenAICompatibleLLM(LLMInterface):
"deepseek",
"volcano",
"openrouter",
"zai",
"opencode-go",
]
if self.provider not in valid_providers:
raise ValueError(f"OpenAICompatibleLLM only supports: {', '.join(valid_providers)}. Got: {self.provider}")
@@ -296,29 +291,13 @@ 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 == "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"
# For ollama/lmstudio, use dummy key if not provided
if self.provider in ("ollama", "lmstudio") and not self.api_key:
self.api_key = "local"
# Validate API key for cloud providers
if (
self.provider
in (
"openai",
"groq",
"minimax",
"deepseek",
"openrouter",
"zai",
"opencode-go",
)
and not self.api_key
):
if self.provider in ("openai", "groq", "minimax", "deepseek", "openrouter") and not self.api_key:
raise ValueError(f"API key is required for {self.provider}")
# Service tier configuration (from config, not env vars)
@@ -579,11 +558,10 @@ class OpenAICompatibleLLM(LLMInterface):
)
# Strip reasoning model thinking tags
# Supports: <think>, <thinking>, <thought>, <reasoning>, |startthink|/|endthink|
# Supports: <think>, <thinking>, <reasoning>, |startthink|/|endthink|
original_len = len(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()
@@ -14,6 +14,8 @@ import re
import time
from typing import TYPE_CHECKING, Any, Awaitable, Callable
import tiktoken
from .models import DirectiveInfo, LLMCall, ReflectAgentResult, TokenUsageSummary, ToolCall
from .prompts import (
_extract_directive_rules,
@@ -21,7 +23,6 @@ from .prompts import (
build_final_system_prompt,
build_system_prompt_for_tools,
)
from .tokenization import count_cl100k_tokens
from .tools_schema import get_reflect_tools
@@ -265,22 +266,25 @@ OUTPUT:"""
return None, 0, 0
_TIKTOKEN_ENCODING = tiktoken.get_encoding("cl100k_base")
def _count_messages_tokens(messages: list[dict[str, Any]]) -> int:
"""Estimate the token count of the messages list using cl100k_base encoding."""
total = 0
for msg in messages:
content = msg.get("content") or ""
if isinstance(content, str):
total += count_cl100k_tokens(content)
total += len(_TIKTOKEN_ENCODING.encode(content))
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and isinstance(part.get("text"), str):
total += count_cl100k_tokens(part["text"])
total += len(_TIKTOKEN_ENCODING.encode(part["text"]))
# Tool call arguments and results also count
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict):
func = tc.get("function", {})
total += count_cl100k_tokens(func.get("arguments", ""))
total += len(_TIKTOKEN_ENCODING.encode(func.get("arguments", "")))
return total
@@ -668,7 +672,7 @@ async def run_reflect_agent(
# must respect max_tokens like the forced-final paths do. If it
# overshoots, run one extra capped call to rewrite it within
# the cap.
if max_tokens is not None and count_cl100k_tokens(answer) > max_tokens:
if max_tokens is not None and len(_TIKTOKEN_ENCODING.encode(answer)) > max_tokens:
rewrite_start = time.time()
rewritten, rewrite_usage = await llm_config.call(
messages=[
@@ -10,7 +10,9 @@ The reflect agent uses hierarchical retrieval:
import json
from typing import Any
from .tokenization import count_cl100k_tokens
import tiktoken
_TIKTOKEN_ENCODING = tiktoken.get_encoding("cl100k_base")
# Fraction of max_context_tokens reserved for tool results in the final synthesis prompt.
# The remainder covers the system prompt, question, bank context, and output tokens.
@@ -451,7 +453,7 @@ def build_final_prompt(
except (TypeError, ValueError):
output_str = str(output)
block = f"\n### From {tool}:\n```json\n{output_str}\n```"
block_tokens = count_cl100k_tokens(block)
block_tokens = len(_TIKTOKEN_ENCODING.encode(block))
if block_tokens > token_budget:
truncated = True
break
@@ -1,17 +0,0 @@
"""Token counting helpers for reflect prompts and agent control flow."""
from functools import lru_cache
import tiktoken
@lru_cache(maxsize=1)
def _get_cl100k_base_encoding() -> tiktoken.Encoding:
# tiktoken downloads this encoding on first lookup when it is not cached.
# Keep the lookup lazy so importing hindsight_api does not depend on network access.
return tiktoken.get_encoding("cl100k_base")
def count_cl100k_tokens(text: str) -> int:
"""Return the number of cl100k_base tokens in text."""
return len(_get_cl100k_base_encoding().encode(text))
@@ -7,7 +7,6 @@ Implements hierarchical retrieval:
3. recall - Raw facts as ground truth
"""
import json
import logging
import uuid
from dataclasses import replace
@@ -23,21 +22,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _document_metadata_from_retain_params(retain_params: Any) -> dict[str, Any] | None:
"""Return document metadata stored under retain_params.metadata."""
if isinstance(retain_params, str):
try:
retain_params = json.loads(retain_params)
except json.JSONDecodeError:
return None
if not isinstance(retain_params, dict):
return None
metadata = retain_params.get("metadata")
return metadata if isinstance(metadata, dict) else None
async def tool_search_mental_models(
memory_engine: "MemoryEngine",
conn: "Connection",
@@ -366,7 +350,7 @@ async def tool_expand(
if all_doc_ids:
docs = await conn.fetch(
f"""
SELECT id, original_text, retain_params
SELECT id, original_text, metadata, retain_params
FROM {fq_table("documents")}
WHERE id = ANY($1) AND bank_id = $2
""",
@@ -412,7 +396,7 @@ async def tool_expand(
item["document"] = {
"id": doc["id"],
"full_text": doc["original_text"],
"metadata": _document_metadata_from_retain_params(doc["retain_params"]),
"metadata": doc["metadata"],
"retain_params": doc["retain_params"],
}
elif memory["document_id"] and depth == "document" and memory["document_id"] in doc_map:
@@ -421,7 +405,7 @@ async def tool_expand(
item["document"] = {
"id": doc["id"],
"full_text": doc["original_text"],
"metadata": _document_metadata_from_retain_params(doc["retain_params"]),
"metadata": doc["metadata"],
"retain_params": doc["retain_params"],
}
@@ -10,7 +10,6 @@ from typing import TypedDict
from pydantic import BaseModel, Field
from ..._vector_index import index_using_clause, uses_per_bank_vector_indexes
from ...config import get_config
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table, get_current_schema
@@ -36,12 +35,15 @@ def _bank_index_name(ft: str, internal_id: str) -> str:
return f"idx_mu_emb_{_BANK_INDEX_FACT_TYPES[ft]}_{uid}"
def _vector_index_clause() -> str | None:
"""Return the USING clause for per-bank vector indexes, if this backend uses them."""
def _vector_index_clause() -> str:
"""Return the USING clause for vector index creation based on the configured extension."""
ext = get_config().vector_extension
if not uses_per_bank_vector_indexes(ext):
return None
return index_using_clause(ext)
if ext == "pgvectorscale":
return "USING diskann (embedding vector_cosine_ops) WITH (num_neighbors = 50)"
elif ext == "vchord":
return "USING vchordrq (embedding vector_l2_ops)"
else: # pgvector (default)
return "USING hnsw (embedding vector_cosine_ops)"
async def create_bank_vector_indexes(conn, bank_id: str, internal_id: str, ops=None) -> None:
@@ -50,26 +52,20 @@ async def create_bank_vector_indexes(conn, bank_id: str, internal_id: str, ops=N
Respects the HINDSIGHT_API_VECTOR_EXTENSION config to use the appropriate
index type (HNSW for pgvector, DiskANN for pgvectorscale, vchordrq for vchord).
AlloyDB ScaNN uses global vector indexes with filtered vector search; it
cannot safely create per-bank indexes at bank-creation time because new
banks have no embedding rows.
Called immediately after the bank row is first inserted. Safe on empty banks
(index build is instant). Idempotent via CREATE INDEX IF NOT EXISTS.
bank_id is escaped for SQL literal safety (apostrophes doubled).
On Oracle 23ai, this is a no-op — Oracle uses a single global vector index
created during migrations. Partial indexes (WHERE clause) are not supported
for Oracle vector indexes.
"""
index_clause = _vector_index_clause()
if index_clause is None:
logger.debug("Skipping per-bank vector indexes for configured backend")
return
await ops.create_bank_vector_indexes(
conn,
fq_table("memory_units"),
bank_id,
internal_id,
index_clause,
_vector_index_clause(),
_BANK_INDEX_FACT_TYPES,
)
@@ -372,49 +368,30 @@ Merged mission:"""
async def list_banks(pool) -> list:
"""
List all banks in the system with summary stats.
List all banks in the system.
Args:
pool: Database connection pool
Returns:
List of dicts with bank info and stats (document_count, fact_count, last_event_at)
List of dicts with bank_id, name, disposition, mission, created_at, updated_at
"""
banks_table = fq_table("banks")
docs_table = fq_table("documents")
mu_table = fq_table("memory_units")
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
f"""
SELECT
b.bank_id, b.name, b.disposition, b.mission,
b.created_at, b.updated_at,
COALESCE(m.fact_count, 0) AS fact_count,
d.last_document_at
FROM {banks_table} b
LEFT JOIN (
SELECT bank_id, MAX(created_at) AS last_document_at
FROM {docs_table}
GROUP BY bank_id
) d ON d.bank_id = b.bank_id
LEFT JOIN (
SELECT bank_id, COUNT(*) AS fact_count
FROM {mu_table}
GROUP BY bank_id
) m ON m.bank_id = b.bank_id
ORDER BY d.last_document_at DESC NULLS LAST, b.updated_at DESC
SELECT bank_id, name, disposition, mission, created_at, updated_at
FROM {fq_table("banks")}
ORDER BY updated_at DESC
"""
)
result = []
for row in rows:
# asyncpg returns JSONB as a string, so parse it
disposition_data = row["disposition"]
if isinstance(disposition_data, str):
disposition_data = json.loads(disposition_data)
last_doc = row["last_document_at"]
result.append(
{
"bank_id": row["bank_id"],
@@ -423,8 +400,6 @@ async def list_banks(pool) -> list:
"mission": row["mission"] or "",
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
"fact_count": row["fact_count"],
"last_document_at": last_doc.isoformat() if last_doc else None,
}
)
@@ -6,7 +6,7 @@ Defines a controlled vocabulary of key:value classification labels
at retain time and stored as entities.
"""
from typing import Any, Literal
from typing import Literal
from pydantic import BaseModel, Field, create_model
@@ -18,28 +18,15 @@ class LabelValue(BaseModel):
description: str = ""
class MapField(BaseModel):
"""A field within a map-type entity label group. Supports recursion via type='map'."""
type: Literal["text", "value", "multi-values", "map"] = "text"
description: str = ""
values: list[LabelValue] = []
fields: dict[str, "MapField"] = {}
MapField.model_rebuild()
class LabelGroup(BaseModel):
"""A label group (dimension) with its type and allowed values."""
key: str
description: str = ""
type: Literal["value", "multi-values", "text", "map"] = "value"
type: Literal["value", "multi-values", "text"] = "value"
optional: bool = True
tag: bool = False
values: list[LabelValue] = []
fields: dict[str, MapField] = {}
class EntityLabelsConfig(BaseModel):
@@ -104,71 +91,6 @@ def _migrate_label_group(raw: dict) -> dict:
return patched
def _build_map_fields_model(fields: dict[str, MapField], model_name: str) -> type[BaseModel] | None:
"""
Build a dynamic Pydantic model for a set of map fields (recursive).
Each field becomes a typed Pydantic field based on its type:
- text → str | None
- value → Literal[...] | None
- multi-values → list[Literal[...]]
- map → list[NestedModel] (recursive)
Returns:
Dynamic Pydantic model class, or None if no fields defined
"""
if not fields:
return None
model_fields: dict[str, Any] = {}
for field_name, map_field in fields.items():
description = map_field.description or field_name
if map_field.type == "map":
nested = _build_map_fields_model(map_field.fields, model_name + field_name.capitalize())
if nested is not None:
model_fields[field_name] = (
list[nested], # type: ignore[valid-type]
Field(default_factory=list, description=description),
)
elif map_field.type == "text":
model_fields[field_name] = (str | None, Field(default=None, description=description))
else:
# value / multi-values — enum-constrained
if not map_field.values:
model_fields[field_name] = (str | None, Field(default=None, description=description))
continue
values = tuple(v.value for v in map_field.values if v.value)
if not values:
model_fields[field_name] = (str | None, Field(default=None, description=description))
continue
literal_type = Literal[values] # type: ignore[valid-type]
if map_field.type == "multi-values":
model_fields[field_name] = (
list[literal_type], # type: ignore[valid-type]
Field(default_factory=list, description=description),
)
else:
model_fields[field_name] = (
literal_type | None, # type: ignore[valid-type]
Field(default=None, description=description),
)
if not model_fields:
return None
return create_model(model_name, **model_fields)
def _build_map_entity_model(group: LabelGroup) -> type[BaseModel] | None:
"""
Build a dynamic Pydantic model for a map-type entity label group.
Delegates to ``_build_map_fields_model`` which handles recursion.
"""
# Capitalize group key for the model name (e.g., "person" → "Person")
model_name = group.key.capitalize() + "Entity"
return _build_map_fields_model(group.fields, model_name)
def build_labels_model(labels_cfg: EntityLabelsConfig) -> type[BaseModel] | None:
"""
Build a dynamic Pydantic model for structured label extraction.
@@ -178,7 +100,6 @@ def build_labels_model(labels_cfg: EntityLabelsConfig) -> type[BaseModel] | None
- type="value", optional=True → Literal["v1","v2"] | None
- type="value", optional=False → Literal["v1","v2"] (required)
- type="multi-values" → list[Literal["v1","v2"]]
- type="map" → list[MapModel] (structured entity)
Args:
labels_cfg: Parsed EntityLabelsConfig
@@ -192,14 +113,7 @@ def build_labels_model(labels_cfg: EntityLabelsConfig) -> type[BaseModel] | None
continue
description = group.description or group.key
if group.type == "map":
map_model = _build_map_entity_model(group)
if map_model is not None:
fields[group.key] = (
list[map_model], # type: ignore[valid-type]
Field(default_factory=list, description=description),
)
elif group.type == "text":
if group.type == "text":
# Free-form: any string value accepted, always optional
fields[group.key] = (str | None, Field(default=None, description=description))
else:
@@ -233,35 +147,18 @@ def build_labels_model(labels_cfg: EntityLabelsConfig) -> type[BaseModel] | None
return create_model("Labels", **fields)
def _is_map_label_entity(text_lower: str, prefix: str, fields: dict[str, MapField]) -> bool:
"""Recursively check if text matches a map field path (e.g. 'person:address:city:...')."""
for field_name, map_field in fields.items():
field_prefix = f"{prefix}{field_name.lower()}:"
if map_field.type == "map" and map_field.fields:
if _is_map_label_entity(text_lower, field_prefix, map_field.fields):
return True
elif text_lower.startswith(field_prefix):
return True
return False
def is_label_entity(text: str, labels_cfg: EntityLabelsConfig, labels_lookup: set[str]) -> bool:
"""
Return True if entity text belongs to any configured label group.
For enum groups: checks the pre-built lookup set.
For text groups: checks that the text starts with a known key prefix.
For map groups: recursively checks ``key:field:...:value`` patterns.
"""
if text.lower() in labels_lookup:
return True
for group in labels_cfg.attributes:
if group.type == "text" and group.key and text.lower().startswith(f"{group.key.lower()}:"):
return True
if group.type == "map" and group.key and group.fields:
prefix = f"{group.key.lower()}:"
if _is_map_label_entity(text.lower(), prefix, group.fields):
return True
return False
@@ -289,8 +186,8 @@ def build_labels_lookup(labels_cfg: EntityLabelsConfig | list | None) -> set[str
valid = set()
for group in labels_cfg.attributes:
if group.type in ("text", "map"):
continue # text: no fixed vocabulary; map: uses three-level key:field:value strings
if group.type == "text":
continue # No fixed vocabulary — all values accepted in post-processing
for v in group.values:
if group.key and v.value:
valid.add(f"{group.key}:{v.value}".lower())
@@ -19,7 +19,6 @@ from ..llm_wrapper import LLMConfig, OutputTooLongError, sanitize_llm_output
from ..response_models import TokenUsage
from .entity_labels import (
EntityLabelsConfig,
MapField,
build_labels_lookup,
build_labels_model,
is_label_entity,
@@ -27,49 +26,6 @@ from .entity_labels import (
)
def _extract_map_entities(
entity_obj: dict,
fields: dict[str, MapField],
prefix: str,
validated_entities: "list[Entity]",
existing_texts_lower: set[str],
) -> None:
"""Recursively extract key:field:value entity strings from a map entity dict."""
for field_name, map_field in fields.items():
field_val = entity_obj.get(field_name)
if field_val is None or field_val == "":
continue
if map_field.type == "map" and map_field.fields:
# Nested map: recurse into each sub-entity
sub_list = field_val if isinstance(field_val, list) else [field_val]
for sub_obj in sub_list:
if isinstance(sub_obj, dict):
_extract_map_entities(
sub_obj,
map_field.fields,
f"{prefix}{field_name}:",
validated_entities,
existing_texts_lower,
)
elif map_field.type == "multi-values":
vals = field_val if isinstance(field_val, list) else [field_val]
for v in vals:
if not isinstance(v, str) or not v.strip() or v.lower() in ("none", "null", "n/a"):
continue
label_str = f"{prefix}{field_name}:{v.strip()}"
if label_str.lower() not in existing_texts_lower:
validated_entities.append(Entity(text=label_str))
existing_texts_lower.add(label_str.lower())
else:
# text or value — single string
if not isinstance(field_val, str) or not field_val.strip() or field_val.lower() in ("none", "null", "n/a"):
continue
label_str = f"{prefix}{field_name}:{field_val.strip()}"
if label_str.lower() not in existing_texts_lower:
validated_entities.append(Entity(text=label_str))
existing_texts_lower.add(label_str.lower())
def _infer_temporal_date(fact_text: str, event_date: datetime | None) -> str | None:
"""
Infer a temporal date from fact text when LLM didn't provide occurred_start.
@@ -775,25 +731,6 @@ Example: "Lost job → couldn't pay rent → moved apartment"
- Fact 2: Moved apartment, causal_relations: [{target_index: 1, relation_type: "caused_by"}]"""
def _append_map_fields_prompt(fields: dict[str, "MapField"], lines: list[str], indent: int = 4) -> None:
"""Recursively append map field descriptions to the prompt lines."""
pad = " " * indent
for field_name, map_field in fields.items():
field_desc = f": {map_field.description}" if map_field.description else ""
if map_field.type == "map" and map_field.fields:
lines.append(f"{pad}{field_name} (object){field_desc}")
_append_map_fields_prompt(map_field.fields, lines, indent + 4)
elif map_field.type == "multi-values":
vals = ", ".join(v.value for v in map_field.values if v.value)
type_hint = f"multi-values: {vals}" if vals else "multi-values"
lines.append(f"{pad}{field_name} ({type_hint}){field_desc}")
elif map_field.type == "value" and map_field.values:
vals = ", ".join(v.value for v in map_field.values if v.value)
lines.append(f"{pad}{field_name} (one of: {vals}){field_desc}")
else:
lines.append(f"{pad}{field_name} (text){field_desc}")
def _build_labels_prompt_section(labels_cfg: EntityLabelsConfig | list | None, free_form_entities: bool = True) -> str:
"""Build the entity labels classification section for the extraction prompt."""
if labels_cfg is None:
@@ -826,14 +763,7 @@ def _build_labels_prompt_section(labels_cfg: EntityLabelsConfig | list | None, f
"",
]
has_classification_attrs = False
has_map_attrs = False
for attr in labels_cfg.attributes:
if attr.type == "map":
has_map_attrs = True
continue
has_classification_attrs = True
if attr.type == "text":
# Free-text: no predefined values — LLM writes any relevant string or null
lines.append(f"- {attr.key} (free text or null): {attr.description}")
@@ -845,31 +775,7 @@ def _build_labels_prompt_section(labels_cfg: EntityLabelsConfig | list | None, f
lines.append(f'"{v.value}"{desc}')
lines.append("")
if has_classification_attrs:
lines.append("Only assign labels when clearly applicable. Leave null/empty if the fact does not match.")
lines.append("")
# Add structured entity types (map groups)
if has_map_attrs:
lines.append("")
lines.append("══════════════════════════════════════════════════════════════════════════")
lines.append("STRUCTURED ENTITY TYPES")
lines.append("══════════════════════════════════════════════════════════════════════════")
lines.append("")
lines.append("For each fact, extract structured entities into the corresponding list field in 'labels'.")
lines.append("Each structured entity type has defined fields. Return a list of objects, one per entity found.")
lines.append("")
for attr in labels_cfg.attributes:
if attr.type != "map" or not attr.fields:
continue
desc = f": {attr.description}" if attr.description else ""
lines.append(f"- {attr.key}{desc}")
_append_map_fields_prompt(attr.fields, lines, indent=4)
lines.append("")
lines.append(
"Only extract structured entities when clearly present in the text. Leave the list empty if none found."
)
lines.append("Only assign labels when clearly applicable. Leave null/empty if the fact does not match.")
return "\n".join(lines)
@@ -1258,19 +1164,6 @@ async def _extract_facts_from_chunk(
value = labels_data.get(group.key)
if not value:
continue
# Map-type groups: recursively extract key:field:value strings
if group.type == "map" and group.fields:
entities_list = value if isinstance(value, list) else [value]
for entity_obj in entities_list:
if isinstance(entity_obj, dict):
_extract_map_entities(
entity_obj,
group.fields,
f"{group.key}:",
validated_entities,
existing_texts_lower,
)
continue
values_list = value if isinstance(value, list) else [value]
for v in values_list:
if not isinstance(v, str) or not v.strip() or v.lower() in ("none", "null", "n/a"):
@@ -1382,9 +1275,14 @@ async def _extract_facts_from_chunk(
f" (current value: {config.retain_max_completion_tokens}, must be > RETAIN_CHUNK_SIZE={config.retain_chunk_size})"
) from e
# Don't retry json_validate_failed here — the inner provider
# loop already retried the 400 error. Re-entering the LLM call
# with the same input just multiplies wasted calls.
if "json_validate_failed" in str(e):
logger.warning(
f" [1.3.{chunk_index + 1}] Attempt {attempt + 1}/{llm_max_retries} failed with JSON validation error: {e}"
)
if attempt < llm_max_retries - 1:
logger.info(f" [1.3.{chunk_index + 1}] Retrying...")
continue
# If it's not a JSON validation error or we're out of retries, re-raise
raise
# If we exhausted all retries, raise the last error or a descriptive fallback
@@ -1557,25 +1455,47 @@ async def extract_facts_from_text(
f"chunk_size={config.retain_chunk_size:,}) - starting parallel LLM extraction"
)
# Transient LLM failures (timeouts, rate limits) are already retried inside
# the provider's inner loop. Content-quality retries (malformed facts) are
# handled by the middle loop in _extract_facts_from_chunk. Adding a third
# retry layer here would multiply wasted calls on deterministic failures
# (see https://github.com/vectorize-io/hindsight/issues/1412).
tasks = [
_extract_facts_with_auto_split(
chunk=chunk,
chunk_index=i,
total_chunks=len(chunks),
event_date=event_date,
context=context,
llm_config=llm_config,
config=config,
agent_name=agent_name,
metadata=metadata,
)
for i, chunk in enumerate(chunks)
]
# Per-chunk retry wrapper: each chunk gets up to MAX_CHUNK_RETRIES attempts.
# This handles transient LLM failures (timeouts, rate limits, malformed responses)
# without discarding the entire batch. If a chunk still fails after all retries,
# the ENTIRE retain fails — we do not accept partial extraction.
MAX_CHUNK_RETRIES = 3
CHUNK_RETRY_BASE_DELAY = 2.0 # seconds, doubles each retry
async def _extract_chunk_with_retry(chunk: str, chunk_index: int) -> tuple:
"""Extract facts from a single chunk with retries on failure."""
last_exception = None
for attempt in range(MAX_CHUNK_RETRIES):
try:
return await _extract_facts_with_auto_split(
chunk=chunk,
chunk_index=chunk_index,
total_chunks=len(chunks),
event_date=event_date,
context=context,
llm_config=llm_config,
config=config,
agent_name=agent_name,
metadata=metadata,
)
except Exception as e:
last_exception = e
if attempt < MAX_CHUNK_RETRIES - 1:
delay = CHUNK_RETRY_BASE_DELAY * (2**attempt)
logger.warning(
f"Chunk {chunk_index}/{len(chunks)} extraction failed "
f"(attempt {attempt + 1}/{MAX_CHUNK_RETRIES}): "
f"{type(e).__name__}. Retrying in {delay:.0f}s..."
)
await asyncio.sleep(delay)
else:
logger.error(
f"Chunk {chunk_index}/{len(chunks)} extraction failed after "
f"{MAX_CHUNK_RETRIES} attempts: {type(e).__name__}: {e}"
)
raise last_exception
tasks = [_extract_chunk_with_retry(chunk, i) for i, chunk in enumerate(chunks)]
# return_exceptions=True so we can collect all results even if some chunks
# exhausted their retries. We check for failures below and fail the retain
@@ -1601,8 +1521,8 @@ async def extract_facts_from_text(
# 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}"
f"Fact extraction failed: {len(failed_chunks)}/{len(chunks)} chunks failed "
f"after {MAX_CHUNK_RETRIES} retries each. First failures: {failed_summary}"
)
return all_facts, chunk_metadata, total_usage
@@ -1943,19 +1863,6 @@ async def extract_facts_from_contents_batch_api(
value = labels_data.get(group.key)
if not value:
continue
# Map-type groups: recursively extract key:field:value strings
if group.type == "map" and group.fields:
entities_list = value if isinstance(value, list) else [value]
for entity_obj in entities_list:
if isinstance(entity_obj, dict):
_extract_map_entities(
entity_obj,
group.fields,
f"{group.key}:",
validated_entities,
existing_texts_lower,
)
continue
values_list = value if isinstance(value, list) else [value]
for v in values_list:
if not isinstance(v, str) or not v.strip() or v.lower() in ("none", "null", "n/a"):
@@ -405,10 +405,8 @@ async def build_entity_links_from_resolved(
# Insert unit-entity links (used in fallback path where Phase 2 didn't do this)
substep_start = time.time()
unit_entity_pairs = []
for idx, (unit_id, _local_idx, fact_date) in enumerate(entity_to_unit):
# Propagate the unit's fact_date so entity_cooccurrences.last_cooccurred
# reflects the event timeline, not the ingest moment.
unit_entity_pairs.append((unit_id, resolved_entity_ids[idx], fact_date))
for idx, (unit_id, _local_idx, _fact_date) in enumerate(entity_to_unit):
unit_entity_pairs.append((unit_id, resolved_entity_ids[idx]))
await entity_resolver.link_units_to_entities_batch(unit_entity_pairs, conn=conn)
_log(
@@ -284,12 +284,10 @@ async def _insert_facts_and_links(
)
# Update semantic_ann_links with remapped IDs for Phase 2
semantic_ann_links = remapped_semantic
# INSERT unit_entities (FK to memory_units, must be in transaction).
# Pass fact_date alongside so entity_cooccurrences.last_cooccurred
# tracks the event timeline, not the ingest moment.
# INSERT unit_entities (FK to memory_units, must be in transaction)
unit_entity_pairs = [
(unit_id, resolved_entity_ids[idx], fact_date)
for idx, (unit_id, _local_idx, fact_date) in enumerate(remapped_entity_to_unit)
(unit_id, resolved_entity_ids[idx])
for idx, (unit_id, _local_idx, _fact_date) in enumerate(remapped_entity_to_unit)
]
await entity_resolver.link_units_to_entities_batch(unit_entity_pairs, conn=conn)
log_buffer.append(f" Insert unit_entities: {len(unit_entity_pairs)} pairs in {time.time() - step_start:.3f}s")
@@ -682,14 +680,6 @@ async def retain_batch(
all_pre_chunks.extend(content_chunks)
chunk_to_content.extend([content_idx] * len(content_chunks))
# Memory: after chunking, the original content bodies in RetainContent are
# no longer needed (all_pre_chunks holds the working set). Clear them so
# Python can reclaim the (potentially multi-MB) strings.
# Note: contents_dicts["content"] is still needed briefly for hash computation
# inside _streaming_retain_batch, but gets cleared there after use.
for content in contents:
content.content = ""
total_pre_chunks = len(all_pre_chunks)
num_batches = (total_pre_chunks + chunk_batch_size - 1) // chunk_batch_size if total_pre_chunks > 0 else 1
log_buffer.append(
@@ -892,15 +882,9 @@ async def _streaming_retain_batch(
# the producer can skip already-extracted chunks to avoid duplicate work.
existing_chunk_hashes: set[str] = set()
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
# Memory: contents_dicts content strings are now captured in combined_content.
# Clear them from the dicts to release the per-item copies (can be multi-MB each).
for d in contents_dicts:
d.pop("content", None)
# Sanitize before hashing to match what handle_document_tracking stores
sanitized_content = fact_extraction._sanitize_text(combined_content) or ""
new_content_hash = hashlib.sha256(sanitized_content.encode()).hexdigest()
# Memory: sanitized_content is only needed for the hash; free it immediately.
sanitized_content = ""
is_recovery = False
try:
@@ -985,17 +969,12 @@ async def _streaming_retain_batch(
schema,
)
await chunk_queue.put((global_idx, content, extracted, processed, chunk_meta, usage))
# Memory: release the chunk text from the shared list now that it's
# been extracted and queued. The queued RetainContent holds its own copy.
all_pre_chunks[global_idx] = ""
tasks: list[asyncio.Task] = []
skipped_total = 0
for i, chunk_text in enumerate(all_pre_chunks):
chunk_hash = chunk_storage.compute_chunk_hash(chunk_text)
if chunk_hash in existing_chunk_hashes:
# Memory: skipped chunks aren't needed either.
all_pre_chunks[i] = ""
skipped_total += 1
continue
tasks.append(asyncio.create_task(_extract_one(i, chunk_text)))
@@ -1056,9 +1035,6 @@ async def _streaming_retain_batch(
is_last: bool,
) -> None:
"""Run Phase 1 + Phase 2 + Phase 3 for a batch of pre-extracted chunks."""
# Allow clearing combined_content after the no-facts skip path runs
# doc tracking — see the assignment further below.
nonlocal combined_content
# Combine results from individual chunk extractions
batch_contents: list[RetainContent] = []
batch_extracted: list = []
@@ -1130,10 +1106,6 @@ async def _streaming_retain_batch(
ops=pool.ops,
)
doc_tracking_done[0] = True
# Memory: combined_content has been persisted; release
# it now so the rest of the consumer loop doesn't pin
# a multi-MB string. Nothing reads it after tracking.
combined_content = ""
log_buffer.append(f"[streaming] Document {effective_doc_id} tracked (0 facts in first batch)")
log_buffer.append(
f"[streaming] Consumer batch {consumer_batch_idx + 1}: "
@@ -1147,9 +1119,6 @@ async def _streaming_retain_batch(
)
async def _run_mini_batch_db_work() -> None:
# Allow clearing combined_content after the doc-tracking call so
# subsequent batches don't carry the per-document text in memory.
nonlocal combined_content
entity_resolver.discard_pending_stats()
mb_start = time.time()
@@ -1241,10 +1210,6 @@ async def _streaming_retain_batch(
)
log_buffer.append(f"[streaming] Document {effective_doc_id} tracked (full content)")
doc_tracking_done[0] = True
# Memory: combined_content is no longer needed after
# this first-batch tracking call. Release it so the
# remaining consumer batches don't pin the string.
combined_content = ""
else:
# --- Later batches: verify we still own the document ---
# If another request took over (cascade-deleted our doc and
@@ -1326,14 +1291,6 @@ async def _streaming_retain_batch(
else:
await _run_mini_batch_db_work()
# Memory: after DB write, clear the batch-local lists that hold extracted
# facts and embedding vectors. These can be large (384 floats per fact ×
# thousands of facts) and are no longer needed after commit.
batch_contents.clear()
batch_extracted.clear()
batch_processed.clear()
batch_chunk_meta.clear()
# ---------------------------------------------------------------------------
# Check if facts are already committed (recovery from previous crash).
# If so, skip extraction+writes and jump straight to final ANN pass.
@@ -1419,9 +1376,6 @@ async def _streaming_retain_batch(
ops=pool.ops,
)
doc_tracking_done[0] = True
# Memory: combined_content has been persisted and won't be
# read again — release the per-document text now.
combined_content = ""
log_buffer.append(f"[streaming] Document {effective_doc_id} tracked (no facts extracted)")
# Mark facts as committed in operation metadata (crash recovery checkpoint)
@@ -191,21 +191,21 @@ class TagGroupLeaf(BaseModel):
class TagGroupAnd(BaseModel):
"""Compound AND group: all child filters must match."""
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
model_config = ConfigDict(populate_by_name=True)
filters: list[TagGroup] = Field(alias="and")
class TagGroupOr(BaseModel):
"""Compound OR group: at least one child filter must match."""
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
model_config = ConfigDict(populate_by_name=True)
filters: list[TagGroup] = Field(alias="or")
class TagGroupNot(BaseModel):
"""Compound NOT group: child filter must NOT match."""
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
model_config = ConfigDict(populate_by_name=True)
filter: TagGroup = Field(alias="not")
@@ -185,8 +185,9 @@ class PostgreSQLDialect(SQLDialect):
extra_where: str = "",
) -> str:
if text_search_extension == "vchord":
# <&> returns a distance (lower = more relevant), negate for score
bm25_score_expr = f"-(search_vector <&> to_bm25query('idx_memory_units_text_search', tokenize({text_param}, 'llmlingua2')))"
bm25_score_expr = (
f"search_vector <&> to_bm25query('idx_memory_units_text_search', tokenize({text_param}, 'llmlingua2'))"
)
bm25_order_by = f"{bm25_score_expr} DESC"
bm25_where_filter = ""
elif text_search_extension == "pg_textsearch":
+8 -17
View File
@@ -28,7 +28,6 @@ from .config import DEFAULT_WORKERS, ENV_HOST, ENV_WORKERS, HindsightConfig, _ge
from .daemon import (
DEFAULT_DAEMON_PORT,
DEFAULT_IDLE_TIMEOUT,
ENV_DAEMON_CHILD,
IdleTimeoutMiddleware,
daemonize,
)
@@ -151,15 +150,8 @@ def main():
args = parser.parse_args()
# Daemon mode handling.
# is_daemon_child is True when we are the re-exec'd child spawned by
# daemonize() or by hindsight-embed's DaemonEmbedManager. The child
# does not have --daemon in its argv, but must still behave as a daemon
# (resolve host/port, enable idle timeout, suppress banner, etc.).
is_daemon_child = os.environ.get(ENV_DAEMON_CHILD) == "1"
is_daemon = args.daemon or is_daemon_child
if is_daemon:
# Daemon mode handling
if args.daemon:
args.host, args.port = resolve_daemon_host_port(
args_host=args.host,
args_port=args.port,
@@ -167,13 +159,12 @@ def main():
config_port=config.port,
)
# Detach into background (parent re-execs and exits; child redirects
# stdio to log file). No lockfile needed port binding prevents
# duplicate daemons.
# Fork into background
# No lockfile needed - port binding prevents duplicate daemons
daemonize()
# Print banner (not in daemon mode)
if not is_daemon:
if not args.daemon:
print()
print_banner()
@@ -182,7 +173,7 @@ def main():
if args.log_level != config.log_level:
config = dataclasses.replace(config, host=args.host, port=args.port, log_level=args.log_level)
config.configure_logging()
if not is_daemon:
if not args.daemon:
config.log_config()
# Register cleanup handlers
@@ -231,7 +222,7 @@ def main():
# Wrap with idle timeout middleware in daemon mode
idle_middleware = None
if is_daemon:
if args.daemon:
idle_middleware = IdleTimeoutMiddleware(app, idle_timeout=args.idle_timeout)
app = idle_middleware
@@ -286,7 +277,7 @@ def main():
uvicorn_config["ssl_certfile"] = args.ssl_certfile
# Print startup info (not in daemon mode)
if not is_daemon:
if not args.daemon:
from .banner import print_startup_info
print_startup_info(
+2 -30
View File
@@ -12,7 +12,6 @@ from datetime import datetime, timezone
from typing import Any, Callable
from fastmcp import FastMCP
from pydantic import TypeAdapter
from hindsight_api import MemoryEngine
from hindsight_api.config import (
@@ -22,12 +21,9 @@ from hindsight_api.config import (
from hindsight_api.engine.audit import AuditEntry, AuditLogger
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
from hindsight_api.engine.search.tags import TagGroup
from hindsight_api.extensions import OperationValidationError
from hindsight_api.models import RequestContext
_TAG_GROUP_LIST_ADAPTER = TypeAdapter(list[TagGroup])
# All tools available in the system (explicit list — no wildcards).
# Defined here (shared module) to avoid circular imports with api/mcp.py.
_ALL_TOOLS: frozenset[str] = frozenset(
@@ -777,7 +773,6 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
types: list[str] | None = None,
tags: list[str] | None = None,
tags_match: str = "any",
tag_groups: list[dict] | None = None,
query_timestamp: str | None = None,
bank_id: str | None = None,
) -> str | dict:
@@ -787,12 +782,8 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
max_tokens: Maximum tokens to return in results (default: 4096)
budget: Search budget - 'low', 'mid', or 'high' (default: 'high'). Higher budgets search more thoroughly.
types: Fact types to include (e.g., ['world', 'experience']). Default: all types.
tags: Optional tags to filter results by (e.g., ['project:alpha']). Mutually exclusive with tag_groups.
tags: Optional tags to filter results by (e.g., ['project:alpha'])
tags_match: How to match tags - 'any' (match any tag) or 'all' (match all tags). Default: 'any'
tag_groups: Compound tag filter using boolean groups (AND-ed together). Each group is a leaf
{"tags": [...], "match": "any_strict"} or compound {"and": [...]}, {"or": [...]}, {"not": {...}}.
Example: [{"not": {"tags": ["closeout"], "match": "any_strict"}}] excludes memories tagged closeout.
Mutually exclusive with tags.
query_timestamp: Temporal context for the query (ISO format, e.g., '2024-01-15T10:30:00Z'). Helps retrieve time-relevant memories.
bank_id: Optional bank to search in (defaults to session bank). Use for cross-bank operations.
"""
@@ -801,11 +792,6 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
if target_bank is None:
return "Error: No bank_id configured"
if tags is not None and tag_groups is not None:
raise ValueError(
"'tags' and 'tag_groups' are mutually exclusive. Use 'tag_groups' for compound filtering."
)
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
budget_enum = budget_map.get(budget.lower(), Budget.HIGH)
fact_types = types if types is not None else list(VALID_RECALL_FACT_TYPES)
@@ -821,8 +807,6 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
if tags is not None:
recall_kwargs["tags"] = tags
recall_kwargs["tags_match"] = tags_match
if tag_groups is not None:
recall_kwargs["tag_groups"] = _TAG_GROUP_LIST_ADAPTER.validate_python(tag_groups)
if query_timestamp is not None:
recall_kwargs["question_date"] = parse_timestamp(query_timestamp)
@@ -848,7 +832,6 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
types: list[str] | None = None,
tags: list[str] | None = None,
tags_match: str = "any",
tag_groups: list[dict] | None = None,
query_timestamp: str | None = None,
) -> dict:
"""
@@ -857,12 +840,8 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
max_tokens: Maximum tokens to return in results (default: 4096)
budget: Search budget - 'low', 'mid', or 'high' (default: 'high'). Higher budgets search more thoroughly.
types: Fact types to include (e.g., ['world', 'experience']). Default: all types.
tags: Optional tags to filter results by (e.g., ['project:alpha']). Mutually exclusive with tag_groups.
tags: Optional tags to filter results by (e.g., ['project:alpha'])
tags_match: How to match tags - 'any' (match any tag) or 'all' (match all tags). Default: 'any'
tag_groups: Compound tag filter using boolean groups (AND-ed together). Each group is a leaf
{"tags": [...], "match": "any_strict"} or compound {"and": [...]}, {"or": [...]}, {"not": {...}}.
Example: [{"not": {"tags": ["closeout"], "match": "any_strict"}}] excludes memories tagged closeout.
Mutually exclusive with tags.
query_timestamp: Temporal context for the query (ISO format, e.g., '2024-01-15T10:30:00Z'). Helps retrieve time-relevant memories.
"""
try:
@@ -870,11 +849,6 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
if target_bank is None:
return {"error": "No bank_id configured", "results": []}
if tags is not None and tag_groups is not None:
raise ValueError(
"'tags' and 'tag_groups' are mutually exclusive. Use 'tag_groups' for compound filtering."
)
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
budget_enum = budget_map.get(budget.lower(), Budget.HIGH)
fact_types = types if types is not None else list(VALID_RECALL_FACT_TYPES)
@@ -890,8 +864,6 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
if tags is not None:
recall_kwargs["tags"] = tags
recall_kwargs["tags_match"] = tags_match
if tag_groups is not None:
recall_kwargs["tag_groups"] = _TAG_GROUP_LIST_ADAPTER.validate_python(tag_groups)
if query_timestamp is not None:
recall_kwargs["question_date"] = parse_timestamp(query_timestamp)
+220 -138
View File
@@ -27,15 +27,6 @@ from alembic.config import Config
from alembic.script.revision import ResolutionError
from sqlalchemy import Connection, create_engine, text
from ._vector_index import (
bootstrap_extension,
detect_vector_extension,
index_type_keyword,
index_using_clause,
minimum_rows_for_index,
should_defer_index_creation,
uses_per_bank_vector_indexes,
)
from .db_url import is_oracle_url, to_libpq_url
from .utils import mask_network_location
@@ -53,28 +44,66 @@ _alembic_lock = threading.Lock()
def _detect_vector_extension(conn, vector_extension: str = "pgvector") -> str:
"""Validate configured vector extension and preserve Azure DiskANN detection."""
return detect_vector_extension(conn, vector_extension)
"""
Validate vector extension: 'pgvector', 'vchord', or 'pgvectorscale'.
Args:
conn: SQLAlchemy connection object
vector_extension: Configured extension ("pgvector", "vchord", or "pgvectorscale")
def _drop_per_bank_vector_indexes(conn: Connection, schema_name: str) -> None:
"""Drop per-bank partial memory_units vector indexes after global ScaNN is ready."""
rows = conn.execute(
text("""
SELECT indexname
FROM pg_indexes
WHERE schemaname = :schema_name
AND tablename = 'memory_units'
AND indexname LIKE 'idx_mu_emb_%'
AND indexdef LIKE '%embedding%'
"""),
{"schema_name": schema_name},
).fetchall()
# DDL identifiers cannot be passed as bound parameters, so escape inline.
safe_schema = schema_name.replace('"', '""')
for row in rows:
safe_index = row[0].replace('"', '""')
conn.execute(text(f'DROP INDEX IF EXISTS "{safe_schema}"."{safe_index}"'))
Returns:
"pgvector", "vchord", "pgvectorscale", or "pg_diskann"
Raises:
RuntimeError: If configured extension is not installed
"""
# Verify the configured extension is installed
if vector_extension == "pgvectorscale":
# pgvectorscale/DiskANN requires pgvector to be installed first
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
if not pgvector_check:
raise RuntimeError(
"DiskANN (pgvectorscale/pg_diskann) requires pgvector to be installed. "
"Install it with: CREATE EXTENSION vector; then CREATE EXTENSION vectorscale CASCADE; (or pg_diskann on Azure)"
)
# Check for either vectorscale (open source) or pg_diskann (Azure)
vectorscale_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")).scalar()
pg_diskann_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_diskann'")).scalar()
if vectorscale_check:
logger.debug("Using vector extension: pgvectorscale (DiskANN)")
return "pgvectorscale"
elif pg_diskann_check:
logger.debug("Using vector extension: pg_diskann (Azure DiskANN)")
return "pg_diskann" # Return distinct name for parameter handling
else:
raise RuntimeError(
"Configured vector extension 'pgvectorscale' not found. "
"Install either:\n"
" - pgvectorscale (open source): CREATE EXTENSION vectorscale CASCADE;\n"
" - pg_diskann (Azure): CREATE EXTENSION pg_diskann CASCADE;"
)
elif vector_extension == "vchord":
vchord_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vchord'")).scalar()
if not vchord_check:
raise RuntimeError(
"Configured vector extension 'vchord' not found. Install it with: CREATE EXTENSION vchord CASCADE;"
)
logger.debug("Using configured vector extension: vchord")
return "vchord"
elif vector_extension == "pgvector":
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
if not pgvector_check:
raise RuntimeError(
"Configured vector extension 'pgvector' not found. Install it with: CREATE EXTENSION vector;"
)
logger.debug("Using configured vector extension: pgvector")
return "pgvector"
else:
raise ValueError(
f"Invalid vector_extension: {vector_extension}. Must be 'pgvector', 'vchord', or 'pgvectorscale'"
)
def _get_schema_lock_id(schema: str) -> int:
@@ -335,8 +364,47 @@ def run_migrations(
"Please install it with: CREATE EXTENSION vector;"
) from e
# If using pgvectorscale, ensure vectorscale extension is also installed
vector_extension = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
bootstrap_extension(conn, vector_extension)
if vector_extension == "pgvectorscale":
logger.debug("Checking pgvectorscale (vectorscale) extension availability...")
vectorscale_check = conn.execute(
text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")
).scalar()
if vectorscale_check:
logger.info("pgvectorscale extension already installed")
else:
# Extension doesn't exist - try to install
logger.info("pgvectorscale extension not found, attempting to install...")
try:
conn.execute(text("CREATE EXTENSION vectorscale CASCADE"))
conn.commit()
logger.info("pgvectorscale extension installed successfully")
except Exception as e:
# Installation failed - check one more time in case another process installed it
conn.rollback()
vectorscale_recheck = conn.execute(
text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")
).fetchone()
if vectorscale_recheck:
logger.warning(
"Could not install pgvectorscale extension (permission denied?), "
"but extension exists. Continuing..."
)
else:
# Extension truly doesn't exist and we can't install it
logger.error(
f"pgvectorscale extension is not installed and cannot be installed: {e}. "
f"Please ensure pgvectorscale is installed by a database administrator. "
f"See: https://github.com/timescale/pgvectorscale#installation"
)
raise RuntimeError(
"pgvectorscale extension is required but not installed. "
"Please install it with: CREATE EXTENSION vectorscale CASCADE;"
) from e
# Commit any pending transaction on the advisory-lock connection
# before running migrations. Some code paths above (e.g., the
@@ -477,10 +545,7 @@ def _migrate_table_embedding_dimension(
logger.info(f"Altering {table_name}.embedding column dimension from {current_dim} to {required_dimension}")
# Drop existing vector index (works for HNSW, DiskANN, vchordrq, and ScaNN)
# The EXCEPTION block handles 'could not open relation with OID' errors that
# occur when concurrent sessions drop schemas (e.g. pytest-xdist workers),
# invalidating pg_indexes OID references mid-cursor-iteration.
# Drop existing vector index (works for both HNSW and vchordrq)
conn.execute(
text(f"""
DO $$
@@ -490,14 +555,11 @@ def _migrate_table_embedding_dimension(
SELECT indexname FROM pg_indexes
WHERE schemaname = '{schema_name}'
AND tablename = '{table_name}'
AND (indexdef LIKE '%hnsw%' OR indexdef LIKE '%vchordrq%' OR indexdef LIKE '%diskann%' OR indexdef LIKE '%scann%')
AND (indexdef LIKE '%hnsw%' OR indexdef LIKE '%vchordrq%' OR indexdef LIKE '%diskann%')
AND indexdef LIKE '%embedding%'
LOOP
EXECUTE 'DROP INDEX IF EXISTS {schema_name}.' || idx_name;
END LOOP;
EXCEPTION WHEN internal_error THEN
-- Stale OID from concurrent schema drop; nothing to drop anyway
NULL;
END $$;
""")
)
@@ -508,34 +570,41 @@ def _migrate_table_embedding_dimension(
conn.commit()
# Recreate index with appropriate type based on detected extension
if vector_ext == "pgvector" and required_dimension > 2000:
raise RuntimeError(
f"Embedding dimension {required_dimension} exceeds pgvector HNSW index limit of 2000. "
f"Use an embedding model with <= 2000 dimensions, or switch to a vector extension "
f"that supports higher dimensions (e.g., pgvectorscale/DiskANN or AlloyDB ScaNN)."
if vector_ext == "pgvectorscale":
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS idx_{table_name}_embedding_diskann
ON {schema_name}.{table_name}
USING diskann (embedding vector_cosine_ops)
WITH (num_neighbors = 50)
""")
)
index_type = index_type_keyword(vector_ext)
if should_defer_index_creation(vector_ext, row_count):
minimum_rows = minimum_rows_for_index(vector_ext)
logger.warning(
"Skipping %s index recreation on %s: AlloyDB ScaNN AUTO indexes need at least %s populated "
"embedding rows; table currently has %s",
vector_ext,
table_name,
minimum_rows,
row_count,
logger.info(f"Created DiskANN index on {table_name} for {required_dimension}-dimensional embeddings")
elif vector_ext == "vchord":
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS idx_{table_name}_embedding_vchordrq
ON {schema_name}.{table_name}
USING vchordrq (embedding vector_l2_ops)
""")
)
return
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS idx_{table_name}_embedding_{index_type}
ON {schema_name}.{table_name}
{index_using_clause(vector_ext)}
""")
)
logger.info(f"Created {index_type} index on {table_name} for {required_dimension}-dimensional embeddings")
logger.info(f"Created vchordrq index on {table_name} for {required_dimension}-dimensional embeddings")
else: # pgvector
if required_dimension > 2000:
raise RuntimeError(
f"Embedding dimension {required_dimension} exceeds pgvector HNSW index limit of 2000. "
f"Use an embedding model with <= 2000 dimensions, or switch to a vector extension "
f"that supports higher dimensions (e.g., pgvectorscale/DiskANN)."
)
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS idx_{table_name}_embedding_hnsw
ON {schema_name}.{table_name}
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64)
""")
)
logger.info(f"Created HNSW index on {table_name} for {required_dimension}-dimensional embeddings")
conn.commit()
logger.info(f"Successfully changed {table_name}.embedding dimension to {required_dimension}")
@@ -559,7 +628,7 @@ def ensure_embedding_dimension(
database_url: SQLAlchemy database URL
required_dimension: The embedding dimension required by the model
schema: Target PostgreSQL schema name (None for public)
vector_extension: Configured vector extension ("pgvector", "vchord", "pgvectorscale", or "scann")
vector_extension: Configured vector extension ("pgvector" or "vchord")
Raises:
RuntimeError: If dimension mismatch with existing data
@@ -607,7 +676,7 @@ def ensure_vector_extension(
Args:
database_url: SQLAlchemy database URL
vector_extension: Configured vector extension ("pgvector", "vchord", "pgvectorscale", or "scann")
vector_extension: Configured vector extension ("pgvector" or "vchord")
schema: Target PostgreSQL schema name (None for public)
Raises:
@@ -628,7 +697,13 @@ def ensure_vector_extension(
("pinned_reflections", "idx_pinned_reflections_embedding"),
]
target_index_type = index_type_keyword(target_ext)
# Determine target index type
if target_ext in ("pgvectorscale", "pg_diskann"):
target_index_type = "diskann"
elif target_ext == "vchord":
target_index_type = "vchordrq"
else:
target_index_type = "hnsw"
mismatched_tables = []
tables_with_data = []
@@ -649,10 +724,6 @@ def ensure_vector_extension(
logger.debug(f"Table {table_name} does not exist in schema '{schema_name}', skipping")
continue
row_count = conn.execute(
text(f"SELECT COUNT(*) FROM {schema_name}.{table_name} WHERE embedding IS NOT NULL")
).scalar()
# Check current index type by querying pg_indexes
current_index_info = conn.execute(
text("""
@@ -666,33 +737,30 @@ def ensure_vector_extension(
).fetchone()
if not current_index_info:
if table_name == "memory_units" and uses_per_bank_vector_indexes(target_ext):
# Check whether per-bank partial vector indexes already cover this table
# (created by the bank_utils lifecycle — no global index needed in that case)
per_bank_index_count = conn.execute(
text("""
SELECT COUNT(*)
FROM pg_indexes
WHERE schemaname = :schema
AND tablename = :table_name
AND indexname LIKE 'idx_mu_emb_%'
"""),
{"schema": schema_name, "table_name": table_name},
).scalar()
if per_bank_index_count and per_bank_index_count > 0:
logger.debug(
f"No global embedding index on {table_name}, but {per_bank_index_count} "
f"per-bank partial vector indexes exist — skipping global index creation"
)
continue
logger.warning(f"No embedding index found for {table_name}, will create it if safe")
mismatched_tables.append((table_name, index_name, None, row_count))
# Check whether per-bank partial HNSW indexes already cover this table
# (created by the bank_utils lifecycle — no global index needed in that case)
per_bank_index_count = conn.execute(
text("""
SELECT COUNT(*)
FROM pg_indexes
WHERE schemaname = :schema
AND tablename = :table_name
AND indexname LIKE 'idx_mu_emb_%'
"""),
{"schema": schema_name, "table_name": table_name},
).scalar()
if per_bank_index_count and per_bank_index_count > 0:
logger.debug(
f"No global embedding index on {table_name}, but {per_bank_index_count} "
f"per-bank partial HNSW indexes exist — skipping global index creation"
)
continue
logger.warning(f"No embedding index found for {table_name}, will create it")
mismatched_tables.append((table_name, index_name, None))
continue
indexdef = current_index_info[0].lower()
if "scann" in indexdef:
current_index_type = "scann"
elif "diskann" in indexdef:
if "diskann" in indexdef:
current_index_type = "diskann"
elif "vchordrq" in indexdef:
current_index_type = "vchordrq"
@@ -707,32 +775,30 @@ def ensure_vector_extension(
logger.info(
f"Index type mismatch on {table_name}: current={current_index_type}, target={target_index_type}"
)
mismatched_tables.append((table_name, index_name, current_index_type, row_count))
mismatched_tables.append((table_name, index_name, current_index_type))
if row_count > 0 and target_ext != "scann":
tables_with_data.append((table_name, row_count, current_index_type))
# Check if table has data
row_count = conn.execute(
text(f"SELECT COUNT(*) FROM {schema_name}.{table_name} WHERE embedding IS NOT NULL")
).scalar()
if row_count > 0:
tables_with_data.append((table_name, row_count))
else:
logger.debug(f"Index type OK for {table_name}: {current_index_type}")
if target_ext == "scann" and table_name == "memory_units":
_drop_per_bank_vector_indexes(conn, schema_name)
conn.commit()
# If no mismatches, we're done
if not mismatched_tables:
logger.debug(f"All vector indexes match configured extension: {target_ext}")
return
# If there's data in any non-ScaNN mismatched table, raise error
# If there's data in any mismatched table, raise error
if tables_with_data:
table_list = ", ".join([f"{table}({count} rows)" for table, count, _ in tables_with_data])
current_index_type = tables_with_data[0][2]
table_list = ", ".join([f"{table}({count} rows)" for table, count in tables_with_data])
# Map index type back to extension name for error message
current_ext_name = {
"diskann": "pgvectorscale",
"vchordrq": "vchord",
"hnsw": "pgvector",
"scann": "scann",
}.get(current_index_type, current_index_type)
current_ext_name = {"diskann": "pgvectorscale", "vchordrq": "vchord", "hnsw": "pgvector"}.get(
current_index_type, current_index_type
)
raise RuntimeError(
f"Cannot change vector extension from {current_index_type} to {target_index_type}: "
@@ -743,28 +809,46 @@ def ensure_vector_extension(
f" 2. Use the current vector extension (set HINDSIGHT_API_VECTOR_EXTENSION='{current_ext_name}')"
)
logger.info(f"Reconciling vector indexes for {target_ext}")
for table_name, index_name, current_type, row_count in mismatched_tables:
if should_defer_index_creation(target_ext, row_count):
minimum_rows = minimum_rows_for_index(target_ext)
logger.warning(
"Skipping %s index creation on %s: AlloyDB ScaNN AUTO indexes need at least %s populated "
"embedding rows; table currently has %s",
target_ext,
table_name,
minimum_rows,
row_count,
)
continue
# Tables are empty, safe to recreate indexes
logger.info(f"Recreating vector indexes for {target_ext}")
for table_name, index_name, current_type in mismatched_tables:
# Drop existing index if it exists
if current_type:
logger.info(f"Dropping {current_type} index on {table_name}")
conn.execute(text(f"DROP INDEX IF EXISTS {schema_name}.{index_name}"))
# Create new index with appropriate type
if target_ext == "pgvector":
if target_ext == "pgvectorscale":
logger.info(f"Creating DiskANN index on {table_name} (pgvectorscale)")
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS {index_name}
ON {schema_name}.{table_name}
USING diskann (embedding vector_cosine_ops)
WITH (num_neighbors = 50)
""")
)
elif target_ext == "pg_diskann":
logger.info(f"Creating DiskANN index on {table_name} (pg_diskann/Azure)")
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS {index_name}
ON {schema_name}.{table_name}
USING diskann (embedding vector_cosine_ops)
WITH (max_neighbors = 50)
""")
)
elif target_ext == "vchord":
logger.info(f"Creating vchordrq index on {table_name}")
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS {index_name}
ON {schema_name}.{table_name}
USING vchordrq (embedding vector_l2_ops)
""")
)
else: # pgvector
# Check embedding dimension — pgvector HNSW indexes only support up to 2000 dims
embed_dim = conn.execute(
text("""
@@ -781,22 +865,20 @@ def ensure_vector_extension(
raise RuntimeError(
f"Embedding dimension {embed_dim} on {table_name} exceeds pgvector HNSW index limit of 2000. "
f"Use an embedding model with <= 2000 dimensions, or switch to a vector extension "
f"that supports higher dimensions (e.g., pgvectorscale/DiskANN or AlloyDB ScaNN)."
f"that supports higher dimensions (e.g., pgvectorscale/DiskANN)."
)
logger.info(f"Creating {target_index_type} index on {table_name}")
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS {index_name}
ON {schema_name}.{table_name}
{index_using_clause(target_ext)}
""")
)
if target_ext == "scann" and table_name == "memory_units":
_drop_per_bank_vector_indexes(conn, schema_name)
logger.info(f"Creating HNSW index on {table_name}")
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS {index_name}
ON {schema_name}.{table_name}
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64)
""")
)
conn.commit()
logger.info(f"Successfully reconciled vector indexes for {target_ext}")
logger.info(f"Successfully migrated vector indexes to {target_ext}")
def ensure_text_search_extension(
@@ -16,7 +16,6 @@ import signal
import socket
import sys
import warnings
from collections.abc import Callable
from ..config import get_config
from ..engine.task_backend import WorkerTaskBackend
@@ -32,26 +31,6 @@ os.environ["TOKENIZERS_PARALLELISM"] = "false"
logger = logging.getLogger(__name__)
def _install_shutdown_signal_handlers(
loop: asyncio.AbstractEventLoop,
handler: Callable[[], None],
) -> bool:
"""Register SIGINT/SIGTERM handlers on the asyncio loop.
Returns True when handlers were installed via ``loop.add_signal_handler``.
Returns False on platforms (Windows ProactorEventLoop) where asyncio
does not implement signal handlers; the caller falls back to Python's
default SIGINT behavior, which still terminates the process on Ctrl+C
but loses the in-loop two-stage graceful shutdown.
"""
try:
loop.add_signal_handler(signal.SIGINT, handler)
loop.add_signal_handler(signal.SIGTERM, handler)
except NotImplementedError:
return False
return True
def create_worker_app(poller: WorkerPoller, memory):
"""Create a minimal FastAPI app for worker metrics and health."""
from fastapi import FastAPI
@@ -264,7 +243,6 @@ def main():
# Setup signal handlers for graceful shutdown using asyncio
shutdown_requested = asyncio.Event()
force_exit = False
async_handlers_installed = False
loop = asyncio.get_event_loop()
@@ -275,26 +253,17 @@ def main():
print("\nReceived second signal, forcing immediate exit...")
force_exit = True
# Restore default handler so third signal kills process
if async_handlers_installed:
loop.remove_signal_handler(signal.SIGINT)
loop.remove_signal_handler(signal.SIGTERM)
loop.remove_signal_handler(signal.SIGINT)
loop.remove_signal_handler(signal.SIGTERM)
sys.exit(1)
else:
print("\nReceived shutdown signal, initiating graceful shutdown...")
print("(Press Ctrl+C again to force immediate exit)")
shutdown_requested.set()
async_handlers_installed = _install_shutdown_signal_handlers(loop, signal_handler)
if not async_handlers_installed:
# Windows ProactorEventLoop: asyncio.add_signal_handler is Unix-only
# and raises NotImplementedError. Default Python SIGINT handler still
# terminates the worker on Ctrl+C, just without the two-stage path.
print(
f"WARN: asyncio signal handlers unavailable on this platform "
f"({sys.platform}); graceful two-stage shutdown disabled, "
f"default Python SIGINT handler remains active.",
flush=True,
)
# Use asyncio's signal handlers which work properly with the event loop
loop.add_signal_handler(signal.SIGINT, signal_handler)
loop.add_signal_handler(signal.SIGTERM, signal_handler)
# Create uvicorn config and server
uvicorn_config = uvicorn.Config(
+68 -111
View File
@@ -14,8 +14,7 @@ import json
import logging
import time
import traceback
from collections import Counter
from collections.abc import Awaitable, Callable, Iterable
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
@@ -38,36 +37,6 @@ STUCK_STACK_INITIAL_THRESHOLD_S = 300
STUCK_STACK_MAX_THRESHOLD_S = 3600 * 6 # cap doubling at 6h
def _summarise_child_error_messages(siblings: "Iterable[Any]") -> str:
"""Pick a representative error message for a parent whose children failed.
Used when a batch_retain parent transitions to 'failed' because at least
one child sub-batch failed. Without this, the parent gets a generic
"One or more sub-batches failed" string and any consumer that reasons
about errors via error_message (dashboards, alert filters, log
aggregators) loses the actual cause -- a class of failures that all
share the same root reason at the child level becomes indistinguishable
at the parent level.
Strategy: pick the most common non-empty error_message among failed
siblings. If they all failed for the same reason (the common case), the
parent inherits that reason verbatim. If they vary, the most-common one
is still a useful representative. Falls back to the legacy generic
string when no failed sibling carries an error_message at all.
"""
failed_errors: list[str] = []
for s in siblings:
if s["status"] != "failed":
continue
msg = (s["error_message"] or "").strip()
if msg:
failed_errors.append(msg)
if not failed_errors:
return "One or more sub-batches failed"
most_common, _count = Counter(failed_errors).most_common(1)[0]
return most_common
@dataclass
class ActiveTaskInfo:
"""Tracking info for an in-flight worker task.
@@ -168,11 +137,6 @@ class WorkerPoller:
self._slot_reservations: dict[str, int] = (
slot_reservations if slot_reservations is not None else {"consolidation": 2}
)
# Cache of which optional PG routines are installed on the server
# (probed once, memoised for the life of the poller).
from ..engine.db.optional_routines import OptionalRoutines
self._optional_routines = OptionalRoutines(self._backend)
self._shutdown = asyncio.Event()
self._current_tasks: set[asyncio.Task] = set()
self._in_flight_count = 0
@@ -198,22 +162,46 @@ class WorkerPoller:
async def _scan_active_schemas(self, schemas: list[str | None]) -> set[str | None]:
"""Find which schemas have pending work.
Prefers a server-side PL/pgSQL routine (single DB round-trip,
~200ms for 1400+ schemas) when ``public.schemas_with_pending_work()``
is installed. The presence check goes through
``OptionalRoutines.is_installed`` which probes ``pg_proc`` once and
caches the result, so we don't generate a server-side error on
every poll cycle when the routine isn't installed.
Tries a server-side PL/pgSQL function first (single DB round-trip,
~200ms for 1400+ schemas). Falls back to per-schema Python EXISTS
queries if the function is not installed (~4ms each).
Falls back to per-schema Python EXISTS queries (~4ms each) on
non-PostgreSQL backends or when the routine isn't installed. See
``hindsight_api.engine.db.optional_routines`` for the canonical
install SQL.
The server-side function should be installed in the ``public``
schema as::
CREATE OR REPLACE FUNCTION public.schemas_with_pending_work()
RETURNS SETOF text AS $$
DECLARE
r RECORD; has_work BOOLEAN;
BEGIN
FOR r IN SELECT nspname FROM pg_namespace
WHERE nspname LIKE 'tenant_%' LOOP
BEGIN
EXECUTE format(
'SELECT EXISTS(SELECT 1 FROM %I.async_operations '
'WHERE status = ''pending'' '
'AND task_payload IS NOT NULL LIMIT 1)',
r.nspname) INTO has_work;
IF has_work THEN RETURN NEXT r.nspname; END IF;
EXCEPTION WHEN OTHERS THEN NULL;
END;
END LOOP;
END $$ LANGUAGE plpgsql STABLE;
In hindsight-cloud deployments this is installed by a Helm hook
job alongside ``total_pending_tasks()``.
"""
async with self._backend.acquire() as conn:
if await self._optional_routines.is_installed(conn, "schemas_with_pending_work"):
rows = await conn.fetch("SELECT * FROM public.schemas_with_pending_work()")
return {r[0] for r in rows}
# The schemas_with_pending_work() PL/pgSQL function is a
# PostgreSQL-specific optimisation installed by Helm hooks in
# hindsight-cloud. Skip on non-PG backends to avoid constant
# ORA-00904 / syntax errors on every poll cycle.
if self._backend.backend_type == "postgresql":
try:
rows = await conn.fetch("SELECT * FROM schemas_with_pending_work()")
return {r[0] for r in rows}
except Exception:
pass
# Fallback: per-schema EXISTS checks from Python
active: set[str | None] = set()
@@ -526,14 +514,10 @@ class WorkerPoller:
if not parent_row:
return
# Check whether all siblings are done. Pull error_message too so a
# parent that fails can inherit a representative child reason --
# otherwise the parent's error_message is generic ("One or more
# sub-batches failed") and downstream consumers (dashboards, alerts,
# filters) lose the actual cause once a batch has children.
# Check whether all siblings are done
siblings = await conn.fetch(
f"""
SELECT status, error_message FROM {table}
SELECT status FROM {table}
WHERE bank_id = $1
AND result_metadata::jsonb @> $2::jsonb
""",
@@ -552,7 +536,7 @@ class WorkerPoller:
WHERE operation_id = $1
""",
uuid.UUID(parent_operation_id),
_summarise_child_error_messages(siblings),
"One or more sub-batches failed",
)
else:
await conn.execute(
@@ -975,30 +959,15 @@ class WorkerPoller:
if len(processing_info) > 10:
processing_str += f" +{len(processing_info) - 10} more"
# Get global stats from DB — scope the heavy COUNT/GROUP BY
# queries to schemas that actually have work. With N tenants the
# full fanout is 2*N queries every PROGRESS_LOG_INTERVAL; scoping
# via the routine (or per-schema EXISTS fallback) reduces this to
# 2*active_schemas which is typically << N.
# Get global stats from DB
schemas = await self._get_schemas()
total_schema_count = len(schemas)
# Schemas with pending async_operations (uses server-side
# routine when installed, falls back to per-schema EXISTS).
schemas_with_pending = await self._scan_active_schemas(schemas)
# Also include schemas that have in-flight tasks on this worker
# so the "processing" worker_id GROUP BY still reports correctly.
schemas_with_active_tasks = {info.schema for info in active_tasks.values()}
schemas_to_query = schemas_with_pending | schemas_with_active_tasks
global_pending = 0
all_worker_counts: dict[str, int] = {}
# operation_type -> aggregated bucket counts across schemas
pending_breakdown: dict[str, dict[str, int]] = {}
async with self._backend.acquire() as conn:
for schema in schemas_to_query:
for schema in schemas:
table = fq_table("async_operations", schema)
# Bucket pending rows by the same predicates the claim query
@@ -1007,24 +976,20 @@ class WorkerPoller:
# retry backoff, etc.).
# Use SUM(CASE WHEN ...) instead of COUNT(*) FILTER (WHERE ...)
# for Oracle compatibility — FILTER is PG-specific.
try:
breakdown_rows = await conn.fetch(
f"""
SELECT
operation_type,
COUNT(*) AS total,
SUM(CASE WHEN task_payload IS NULL THEN 1 ELSE 0 END) AS payload_null,
SUM(CASE WHEN next_retry_at IS NOT NULL AND next_retry_at > now()
THEN 1 ELSE 0 END) AS retry_blocked,
SUM(CASE WHEN worker_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned
FROM {table}
WHERE status = 'pending'
GROUP BY operation_type
"""
)
except Exception:
# Schema may be partially provisioned (table missing).
breakdown_rows = []
breakdown_rows = await conn.fetch(
f"""
SELECT
operation_type,
COUNT(*) AS total,
SUM(CASE WHEN task_payload IS NULL THEN 1 ELSE 0 END) AS payload_null,
SUM(CASE WHEN next_retry_at IS NOT NULL AND next_retry_at > now()
THEN 1 ELSE 0 END) AS retry_blocked,
SUM(CASE WHEN worker_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned
FROM {table}
WHERE status = 'pending'
GROUP BY operation_type
"""
)
for br in breakdown_rows:
op_type = br["operation_type"] or "unknown"
bucket = pending_breakdown.setdefault(
@@ -1036,17 +1001,14 @@ class WorkerPoller:
bucket["assigned"] += br["assigned"]
global_pending += br["total"]
try:
worker_rows = await conn.fetch(
f"""
SELECT worker_id, COUNT(*) as count
FROM {table}
WHERE status = 'processing'
GROUP BY worker_id
"""
)
except Exception:
worker_rows = []
worker_rows = await conn.fetch(
f"""
SELECT worker_id, COUNT(*) as count
FROM {table}
WHERE status = 'processing'
GROUP BY worker_id
"""
)
for wr in worker_rows:
wid = wr["worker_id"] or "unknown"
all_worker_counts[wid] = all_worker_counts.get(wid, 0) + wr["count"]
@@ -1062,19 +1024,14 @@ class WorkerPoller:
pool_str = self._format_pool_stats()
proc_str = self._format_proc_stats()
queried_count = len(schemas_to_query)
# Display queried schemas (cap at 20 for readability)
queried_list = sorted(s if s else "default" for s in schemas_to_query)
schemas_str = ", ".join(queried_list[:20])
if len(queried_list) > 20:
schemas_str += f" +{len(queried_list) - 20} more"
# Display None as "default" in logs
schemas_str = ", ".join(s if s else "default" for s in schemas)
logger.info(
f"[WORKER_STATS] worker={self._worker_id} "
f"slots={in_flight}/{self._max_slots} | "
f"reserved: [{reserved_str}] | "
f"shared={tasks_in_shared}/{shared_pool_size}(avail={shared_available}) | "
f"global: pending={global_pending} "
f"(queried={queried_count}/{total_schema_count} schemas: {schemas_str}) | "
f"global: pending={global_pending} (schemas: {schemas_str}) | "
f"others: {others_str} | "
f"pool: {pool_str} | "
f"proc: {proc_str} | "
+4 -5
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api-slim"
version = "0.6.2"
version = "0.5.6"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -47,14 +47,14 @@ dependencies = [
"anthropic>=0.40.0",
"typer>=0.9.0",
"cohere>=5.0.0",
"litellm>=1.83.14", # 1.82.7/1.82.8 had a supply chain compromise (yanked); 1.83.0+ also fixes GHSA-jjhc-v7c2-5hh6 / GHSA-53mr-6c8q-9789 / GHSA-pq44-5pcq-4r5g / GHSA-8cjq-wjmh-q42r
"litellm>=1.83.0", # 1.82.7/1.82.8 had a supply chain compromise (yanked); 1.83.0+ also fixes GHSA-jjhc-v7c2-5hh6 / GHSA-53mr-6c8q-9789
"markitdown[pdf,docx,pptx,xlsx,xls]>=0.1.4", # File to markdown conversion
"obstore>=0.4.0", # S3/GCS/Azure object storage client (Rust-backed)
"winloop>=0.1.0; sys_platform == 'win32'",
"uvloop>=0.22.1; sys_platform != 'win32'",
# Transitive dependency security fixes
"pyasn1>=0.6.3", # DoS vulnerability fix
"urllib3>=2.7.0", # Decompression-bomb safeguards bypass + sensitive header forwarding fixes
"urllib3>=2.6.3", # Decompression-bomb safeguards bypass fix
"langchain-core>=1.2.22", # Path traversal in legacy load_prompt functions fix
"langsmith>=0.6.3", # SSRF via tracing header injection fix
"protobuf>=6.33.5", # JSON recursion depth bypass fix
@@ -93,7 +93,7 @@ local-llm = [
"huggingface-hub>=0.20.0",
]
embedded-db = [
"pg0-embedded>=0.14.0",
"pg0-embedded>=0.13.0",
]
oracle = [
"oracledb>=2.5.0",
@@ -141,7 +141,6 @@ log_cli_date_format = "%Y-%m-%d %H:%M:%S"
addopts = "--timeout 300 -n 8 --dist loadgroup --durations=10 -v"
markers = [
"oracle: Oracle 23ai integration tests (require ORACLE_TEST_DSN env var)",
"hs_llm_mat: LLM minimum acceptance tests — run in CI matrix across multiple providers",
]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
@@ -1,535 +0,0 @@
"""Tests for Codex OAuth token refresh (issue #1637).
The Codex provider was originally a startup-only credential loader: it read
``~/.codex/auth.json`` once and used the cached access_token forever. These
tests pin the new automatic-refresh behavior:
- ``refresh_token`` is now actually loaded from auth.json.
- The provider proactively refreshes ~60s before the JWT ``exp`` claim.
- It reactively refreshes once on a 401/403 from the Codex backend.
- The OAuth refresh request shape mirrors the canonical ``@openai/codex``
CLI (POST https://auth.openai.com/oauth/token, JSON body with hardcoded
client_id, grant_type=refresh_token).
- Terminal error codes (refresh_token_expired/reused/invalidated) raise a
permanent error and do not loop.
- Concurrent callers serialize through a single-flight lock.
- ``auth.json`` is persisted atomically via tempfile+rename with mode 0600.
Tests construct ``CodexLLM`` with ``_load_codex_auth`` mocked, then drive
JWT exp / network / persistence paths through targeted patches.
"""
from __future__ import annotations
import asyncio
import base64
import json
import os
import stat
import sys
import time
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from hindsight_api.engine.providers.codex_llm import (
_CODEX_CLIENT_ID,
_CODEX_REFRESH_TOKEN_URL,
CodexLLM,
CodexRefreshExpiredError,
)
def _make_jwt(exp_unixtime: int | None) -> str:
"""Build a minimal JWT-shaped token with the given ``exp`` claim.
Signature segment is a placeholder we don't verify, we only decode
the payload to read ``exp``.
"""
header = base64.urlsafe_b64encode(json.dumps({"alg": "none"}).encode()).rstrip(b"=").decode()
payload_dict: dict[str, object] = {}
if exp_unixtime is not None:
payload_dict["exp"] = exp_unixtime
payload = base64.urlsafe_b64encode(json.dumps(payload_dict).encode()).rstrip(b"=").decode()
signature = "sig"
return f"{header}.{payload}.{signature}"
def _build_llm(refresh_token: str | None = "rt-initial", access_token: str | None = None) -> CodexLLM:
"""Construct a CodexLLM with patched auth-file reads."""
if access_token is None:
access_token = _make_jwt(int(time.time()) + 3600) # fresh by default
with (
patch.object(CodexLLM, "_load_codex_auth", return_value=(access_token, "acct-123")),
patch.object(CodexLLM, "_load_codex_refresh_token", return_value=refresh_token),
):
return CodexLLM(
provider="openai-codex",
api_key="ignored",
base_url="https://chatgpt.com/backend-api",
model="gpt-5.4-mini",
)
# ---------------------------------------------------------------------------
# JWT exp decode
# ---------------------------------------------------------------------------
def test_jwt_exp_decode_returns_int_for_valid_token():
token = _make_jwt(1_800_000_000)
assert CodexLLM._decode_jwt_exp_unixtime(token) == 1_800_000_000
def test_jwt_exp_decode_returns_none_when_exp_missing():
token = _make_jwt(None)
assert CodexLLM._decode_jwt_exp_unixtime(token) is None
def test_jwt_exp_decode_returns_none_for_malformed_token():
assert CodexLLM._decode_jwt_exp_unixtime("not.a.real.jwt") is None
assert CodexLLM._decode_jwt_exp_unixtime("only-one-segment") is None
assert CodexLLM._decode_jwt_exp_unixtime("a.!!notbase64!!.c") is None
# ---------------------------------------------------------------------------
# Staleness
# ---------------------------------------------------------------------------
def test_token_is_stale_true_when_expired():
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(access_token=expired)
assert llm._token_is_stale() is True
def test_token_is_stale_true_within_skew_window():
# 30s before expiry, default skew is 60s → should be considered stale.
soon = _make_jwt(int(time.time()) + 30)
llm = _build_llm(access_token=soon)
assert llm._token_is_stale() is True
def test_token_is_stale_false_when_far_from_expiry():
far = _make_jwt(int(time.time()) + 3600)
llm = _build_llm(access_token=far)
assert llm._token_is_stale() is False
def test_token_is_stale_false_when_exp_unparseable():
# When we can't decide, we'd rather use a possibly-expired token and
# recover via the reactive 401 path than refresh aggressively.
llm = _build_llm(access_token="opaque-token-no-jwt-structure")
assert llm._token_is_stale() is False
# ---------------------------------------------------------------------------
# refresh_token loading
# ---------------------------------------------------------------------------
def test_refresh_token_loaded_from_auth_file(tmp_path: Path):
auth_file = tmp_path / "auth.json"
auth_file.write_text(
json.dumps(
{
"auth_mode": "chatgpt",
"tokens": {
"access_token": "at",
"refresh_token": "rt-from-disk",
"account_id": "acct",
},
}
)
)
with patch.object(CodexLLM, "_load_codex_auth", return_value=("at", "acct")):
llm = CodexLLM(
provider="openai-codex",
api_key="ignored",
base_url="https://chatgpt.com/backend-api",
model="gpt-5.4-mini",
)
# Now point the auth_file at our tmp file and reload.
llm._auth_file = auth_file
assert llm._load_codex_refresh_token() == "rt-from-disk"
def test_refresh_token_returns_none_when_field_absent(tmp_path: Path):
auth_file = tmp_path / "auth.json"
auth_file.write_text(json.dumps({"auth_mode": "chatgpt", "tokens": {"access_token": "at"}}))
llm = _build_llm()
llm._auth_file = auth_file
assert llm._load_codex_refresh_token() is None
def test_refresh_token_returns_none_when_file_missing(tmp_path: Path):
llm = _build_llm()
llm._auth_file = tmp_path / "definitely-not-here.json"
assert llm._load_codex_refresh_token() is None
# ---------------------------------------------------------------------------
# Atomic persistence
# ---------------------------------------------------------------------------
def test_persist_auth_atomic_writes_mode_0600_and_preserves_fields(tmp_path: Path):
auth_file = tmp_path / "auth.json"
auth_file.write_text(
json.dumps(
{
"OPENAI_API_KEY": None,
"auth_mode": "chatgpt",
"tokens": {
"access_token": "old",
"refresh_token": "rt-old",
"account_id": "acct-keep",
"id_token": {"email": "[email protected]"},
},
"last_refresh": "2026-01-01T00:00:00Z",
}
)
)
llm = _build_llm()
llm._auth_file = auth_file
llm._persist_auth_atomic({"access_token": "new", "refresh_token": "rt-new"})
written = json.loads(auth_file.read_text())
assert written["tokens"]["access_token"] == "new"
assert written["tokens"]["refresh_token"] == "rt-new"
# Untouched fields are preserved (account_id, id_token, auth_mode).
assert written["tokens"]["account_id"] == "acct-keep"
assert written["tokens"]["id_token"] == {"email": "[email protected]"}
assert written["auth_mode"] == "chatgpt"
# last_refresh got bumped to a new ISO-8601 UTC timestamp.
assert written["last_refresh"] != "2026-01-01T00:00:00Z"
assert written["last_refresh"].endswith("Z")
if sys.platform != "win32":
mode = stat.S_IMODE(auth_file.stat().st_mode)
assert mode == 0o600, f"expected 0600, got {oct(mode)}"
def test_persist_auth_atomic_does_not_leak_tempfile_on_success(tmp_path: Path):
auth_file = tmp_path / "auth.json"
auth_file.write_text(json.dumps({"tokens": {"access_token": "old"}}))
llm = _build_llm()
llm._auth_file = auth_file
llm._persist_auth_atomic({"access_token": "new"})
# No sibling tempfile should remain — atomic rename consumed it.
siblings = [p.name for p in tmp_path.iterdir()]
assert siblings == ["auth.json"], f"unexpected leftover files: {siblings}"
# ---------------------------------------------------------------------------
# _refresh_oauth_tokens — request shape, in-memory update, rotation
# ---------------------------------------------------------------------------
def _refresh_response(status_code: int, body: dict | str) -> MagicMock:
response = MagicMock()
response.status_code = status_code
if isinstance(body, dict):
response.json.return_value = body
response.text = json.dumps(body)
else:
response.json.side_effect = json.JSONDecodeError("nope", body, 0)
response.text = body
return response
@pytest.mark.asyncio
async def test_refresh_sends_canonical_request_shape(tmp_path: Path):
"""POST JSON body with client_id + grant_type=refresh_token + refresh_token."""
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt-current", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-current"}}))
fresh_access = _make_jwt(int(time.time()) + 3600)
refresh_resp = _refresh_response(200, {"access_token": fresh_access, "refresh_token": "rt-rotated"})
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=refresh_resp) as mock_post:
await llm._refresh_oauth_tokens()
call_args = mock_post.call_args
assert call_args.args[0] == _CODEX_REFRESH_TOKEN_URL
assert call_args.kwargs["headers"]["Content-Type"] == "application/json"
assert call_args.kwargs["json"] == {
"client_id": _CODEX_CLIENT_ID,
"grant_type": "refresh_token",
"refresh_token": "rt-current",
}
@pytest.mark.asyncio
async def test_refresh_updates_in_memory_credentials(tmp_path: Path):
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt-old", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-old"}}))
new_access = _make_jwt(int(time.time()) + 3600)
refresh_resp = _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-new"})
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=refresh_resp):
await llm._refresh_oauth_tokens()
assert llm.access_token == new_access
assert llm.refresh_token == "rt-new"
@pytest.mark.asyncio
async def test_refresh_keeps_existing_refresh_token_when_server_omits_one(tmp_path: Path):
"""If the OAuth response has no ``refresh_token`` field, keep the one we have."""
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt-keep", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-keep"}}))
new_access = _make_jwt(int(time.time()) + 3600)
refresh_resp = _refresh_response(200, {"access_token": new_access})
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=refresh_resp):
await llm._refresh_oauth_tokens()
assert llm.refresh_token == "rt-keep"
@pytest.mark.asyncio
async def test_refresh_raises_permanent_error_on_terminal_oauth_code(tmp_path: Path):
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt-stale", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-stale"}}))
bad_resp = _refresh_response(401, {"error": {"code": "refresh_token_expired"}})
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=bad_resp):
with pytest.raises(CodexRefreshExpiredError):
await llm._refresh_oauth_tokens()
@pytest.mark.asyncio
async def test_refresh_raises_permanent_error_on_unknown_401(tmp_path: Path):
"""Any 401 from the refresh endpoint is treated as permanent — matches upstream Rust classification."""
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt-stale", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-stale"}}))
bad_resp = _refresh_response(401, {"error": "something_else"})
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=bad_resp):
with pytest.raises(CodexRefreshExpiredError):
await llm._refresh_oauth_tokens()
@pytest.mark.asyncio
async def test_refresh_raises_runtime_error_on_5xx(tmp_path: Path):
"""5xx is transient from the caller's perspective — surface as RuntimeError, not CodexRefreshExpiredError."""
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt-current", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt-current"}}))
bad_resp = _refresh_response(503, "service unavailable")
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=bad_resp):
with pytest.raises(RuntimeError) as exc_info:
await llm._refresh_oauth_tokens()
assert not isinstance(exc_info.value, CodexRefreshExpiredError)
@pytest.mark.asyncio
async def test_refresh_does_not_log_token_values(tmp_path: Path, caplog):
expired = _make_jwt(int(time.time()) - 60)
secret_rt = "rt-DO-NOT-LEAK-THIS"
llm = _build_llm(refresh_token=secret_rt, access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": secret_rt}}))
new_access = _make_jwt(int(time.time()) + 3600)
refresh_resp = _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-also-secret"})
with patch.object(llm._client, "post", new_callable=AsyncMock, return_value=refresh_resp):
with caplog.at_level("DEBUG"):
await llm._refresh_oauth_tokens()
log_text = "\n".join(record.getMessage() for record in caplog.records)
assert secret_rt not in log_text
assert new_access not in log_text
assert "rt-also-secret" not in log_text
# ---------------------------------------------------------------------------
# Single-flight under concurrent callers
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_concurrent_ensure_fresh_token_calls_produce_one_refresh(tmp_path: Path):
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": "x", "refresh_token": "rt"}}))
new_access = _make_jwt(int(time.time()) + 3600)
call_count = 0
async def fake_post(*args, **kwargs):
nonlocal call_count
call_count += 1
# Simulate non-zero refresh latency so concurrent callers actually queue.
await asyncio.sleep(0.01)
return _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-new"})
with patch.object(llm._client, "post", new=fake_post):
await asyncio.gather(*(llm._ensure_fresh_token() for _ in range(10)))
assert call_count == 1, f"expected 1 network refresh under contention, got {call_count}"
# ---------------------------------------------------------------------------
# Reactive 401 retry on the request path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_call_reactively_refreshes_on_401_and_retries(tmp_path: Path):
"""A backend 401 triggers one refresh + retry instead of immediately raising."""
fresh = _make_jwt(int(time.time()) + 3600) # not stale; the 401 is the trigger
llm = _build_llm(refresh_token="rt", access_token=fresh)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": fresh, "refresh_token": "rt"}}))
new_access = _make_jwt(int(time.time()) + 3600)
# First post → 401 (backend rejects the token). After refresh, second post → 200.
success_resp = MagicMock()
success_resp.status_code = 200
success_resp.raise_for_status.return_value = None
fail_response = MagicMock()
fail_response.status_code = 401
fail_response.text = "unauthorized"
fail_exc = httpx.HTTPStatusError("401", request=MagicMock(), response=fail_response)
success_resp.raise_for_status = MagicMock(return_value=None)
post_responses = [fail_exc, success_resp]
async def fake_post(*args, **kwargs):
item = post_responses.pop(0)
if isinstance(item, Exception):
raise item
return item
refresh_resp = _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-new"})
call_count = {"refresh": 0, "post": 0}
async def counting_post(url, **kwargs):
if url == _CODEX_REFRESH_TOKEN_URL:
call_count["refresh"] += 1
return refresh_resp
call_count["post"] += 1
# First backend call fails with 401 wrapped in an HTTPStatusError-style response,
# second succeeds.
if call_count["post"] == 1:
raise httpx.HTTPStatusError("401", request=MagicMock(), response=fail_response)
return success_resp
with (
patch.object(llm._client, "post", new=counting_post),
patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock, return_value="ok"),
):
result = await llm.call(
messages=[{"role": "user", "content": "ping"}],
max_retries=0,
initial_backoff=0.0,
max_backoff=0.0,
)
assert result == "ok"
assert call_count["refresh"] == 1
assert call_count["post"] == 2 # one 401, one success after refresh
assert llm.access_token == new_access
@pytest.mark.asyncio
async def test_call_proactively_refreshes_when_token_is_stale(tmp_path: Path):
"""A near-expiry token triggers refresh BEFORE the request is sent."""
expired = _make_jwt(int(time.time()) - 60)
llm = _build_llm(refresh_token="rt", access_token=expired)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": expired, "refresh_token": "rt"}}))
new_access = _make_jwt(int(time.time()) + 3600)
refresh_resp = _refresh_response(200, {"access_token": new_access, "refresh_token": "rt-new"})
success_resp = MagicMock()
success_resp.status_code = 200
success_resp.raise_for_status.return_value = None
call_order: list[str] = []
async def fake_post(url, **kwargs):
if url == _CODEX_REFRESH_TOKEN_URL:
call_order.append("refresh")
return refresh_resp
call_order.append("backend")
# Assert that by the time the backend is called, the new token is in use.
assert kwargs["headers"]["Authorization"] == f"Bearer {new_access}"
return success_resp
with (
patch.object(llm._client, "post", new=fake_post),
patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock, return_value="ok"),
):
await llm.call(
messages=[{"role": "user", "content": "ping"}],
max_retries=0,
initial_backoff=0.0,
max_backoff=0.0,
)
assert call_order == ["refresh", "backend"], "expected proactive refresh BEFORE the backend call"
@pytest.mark.asyncio
async def test_call_does_not_refresh_when_token_is_fresh(tmp_path: Path):
fresh = _make_jwt(int(time.time()) + 3600)
llm = _build_llm(refresh_token="rt", access_token=fresh)
llm._auth_file = tmp_path / "auth.json"
llm._auth_file.write_text(json.dumps({"tokens": {"access_token": fresh, "refresh_token": "rt"}}))
success_resp = MagicMock()
success_resp.status_code = 200
success_resp.raise_for_status.return_value = None
call_count = {"refresh": 0, "backend": 0}
async def fake_post(url, **kwargs):
if url == _CODEX_REFRESH_TOKEN_URL:
call_count["refresh"] += 1
raise AssertionError("refresh endpoint should not be hit for a fresh token")
call_count["backend"] += 1
return success_resp
with (
patch.object(llm._client, "post", new=fake_post),
patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock, return_value="ok"),
):
await llm.call(
messages=[{"role": "user", "content": "ping"}],
max_retries=0,
initial_backoff=0.0,
max_backoff=0.0,
)
assert call_count == {"refresh": 0, "backend": 1}
@@ -128,74 +128,6 @@ def test_log_config_masks_database_urls(caplog):
assert "postgresql://***:***@db-admin:5432/hindsight_db" in log_output
def test_read_database_url_defaults_to_none_when_unset(monkeypatch):
"""Without HINDSIGHT_API_READ_DATABASE_URL, the field is None — engine
will alias the read backend to the primary, preserving today's
single-pool behaviour byte-for-bit. This is the most important guarantee
of the change: zero-config means zero behaviour change.
"""
from hindsight_api.config import HindsightConfig
monkeypatch.delenv("HINDSIGHT_API_READ_DATABASE_URL", raising=False)
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.read_database_url is None
def test_read_database_url_is_loaded_when_set(monkeypatch):
"""When HINDSIGHT_API_READ_DATABASE_URL is set, the value flows into
config so MemoryEngine.initialize() will open a second backend against
that URL for recall queries.
"""
from hindsight_api.config import HindsightConfig
read_url = "postgresql://reader:[email protected]:5432/hindsight"
monkeypatch.setenv("HINDSIGHT_API_READ_DATABASE_URL", read_url)
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.read_database_url == read_url
def test_read_database_url_empty_string_is_treated_as_unset(monkeypatch):
"""Helm sometimes renders an unset env var as the empty string. Treat it
the same as unset so deployments that conditionally set the var don't
accidentally try to open a pool against `''`.
"""
from hindsight_api.config import HindsightConfig
monkeypatch.setenv("HINDSIGHT_API_READ_DATABASE_URL", "")
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
config = HindsightConfig.from_env()
assert config.read_database_url is None
def test_log_config_masks_read_database_url(monkeypatch, caplog):
"""Read-replica URL credentials must be masked in startup logs, same as
the primary URL.
"""
from hindsight_api.config import HindsightConfig
monkeypatch.setenv("HINDSIGHT_API_DATABASE_URL", "postgresql://hindsight:pw@primary:5432/db")
monkeypatch.setenv("HINDSIGHT_API_READ_DATABASE_URL", "postgresql://reader:replica-secret@replica:5432/db")
monkeypatch.setenv("HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS", "64000")
monkeypatch.setenv("HINDSIGHT_API_RETAIN_CHUNK_SIZE", "3000")
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
caplog.set_level(logging.INFO, logger="hindsight_api.config")
config = HindsightConfig.from_env()
config.log_config()
log_output = "\n".join(record.getMessage() for record in caplog.records)
assert "reader" not in log_output
assert "replica-secret" not in log_output
assert "Read database" in log_output
assert "postgresql://***:***@replica:5432/db" in log_output
# Note: The BadRequestError wrapping is implemented in fact_extraction.py
# but requires a complex integration test setup. The functionality is
# straightforward: when a BadRequestError containing keywords like
@@ -335,7 +335,6 @@ class TestConsolidationIntegration:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.hs_llm_mat
@pytest.mark.asyncio
async def test_consolidation_merges_only_redundant_facts(self, memory: MemoryEngine, request_context):
"""Test that consolidation only merges truly redundant facts.
@@ -69,76 +69,15 @@ def create_isolated_schema(db_url: str, schema_name: str, dimension: int | None
# Adjust embedding dimension if specified
if dimension is not None:
_ensure_embedding_dimension_with_retry(db_url, dimension, schema=schema_name)
ensure_embedding_dimension(db_url, dimension, schema=schema_name)
def drop_schema(db_url: str, schema_name: str):
"""Drop an isolated schema.
Retries once on InternalError (e.g. 'could not open relation with OID')
which can happen when pg0 has concurrent connections referencing the schema.
"""
"""Drop an isolated schema."""
engine = create_engine(db_url)
for attempt in range(2):
try:
with engine.connect() as conn:
conn.execute(text(f"DROP SCHEMA IF EXISTS {schema_name} CASCADE"))
conn.commit()
return
except Exception:
if attempt == 0:
import time
time.sleep(0.5)
# Best-effort teardown — don't fail the test over cleanup issues
def _ensure_embedding_dimension_with_retry(db_url: str, dimension: int, schema: str):
"""Wrapper around ensure_embedding_dimension with OID race retry.
pg0 with concurrent xdist workers can cause 'could not open relation with OID'
when one worker's DROP SCHEMA CASCADE invalidates pg_indexes references mid-query.
"""
import time
for attempt in range(3):
try:
ensure_embedding_dimension(db_url, dimension, schema=schema)
return
except Exception as e:
if "could not open relation with OID" in str(e) and attempt < 2:
time.sleep(0.5)
continue
raise
def _assert_raises_runtime_error_with_retry(
db_url: str,
dimension: int,
schema: str,
expected_messages: list[str],
):
"""Assert ensure_embedding_dimension raises RuntimeError, retrying on transient OID errors.
Concurrent xdist workers can cause 'could not open relation with OID' errors
that mask the expected RuntimeError. This retries to give the system a chance to
reach the actual dimension-mismatch check.
"""
import time
for attempt in range(3):
try:
ensure_embedding_dimension(db_url, dimension, schema=schema)
raise AssertionError("Expected RuntimeError but ensure_embedding_dimension succeeded")
except RuntimeError as e:
for msg in expected_messages:
assert msg in str(e), f"Expected '{msg}' in error message, got: {e}"
return
except Exception as e:
if "could not open relation with OID" in str(e) and attempt < 2:
time.sleep(0.5)
continue
raise
with engine.connect() as conn:
conn.execute(text(f"DROP SCHEMA IF EXISTS {schema_name} CASCADE"))
conn.commit()
def get_column_dimension(db_url: str, schema: str = "public", table: str = "memory_units") -> int | None:
@@ -249,7 +188,7 @@ class TestEmbeddingDimension:
assert initial_dim == 384, f"Expected 384, got {initial_dim}"
# Call ensure_embedding_dimension with matching dimension
_ensure_embedding_dimension_with_retry(db_url, 384, schema=schema)
ensure_embedding_dimension(db_url, 384, schema=schema)
# Dimension should still be 384
assert get_column_dimension(db_url, schema) == 384
@@ -263,14 +202,14 @@ class TestEmbeddingDimension:
assert get_row_count(db_url, schema) == 0
# Change dimension to 768
_ensure_embedding_dimension_with_retry(db_url, 768, schema=schema)
ensure_embedding_dimension(db_url, 768, schema=schema)
# Verify dimension changed
new_dim = get_column_dimension(db_url, schema)
assert new_dim == 768, f"Expected 768, got {new_dim}"
# Change back to 384 for other tests
_ensure_embedding_dimension_with_retry(db_url, 384, schema=schema)
ensure_embedding_dimension(db_url, 384, schema=schema)
assert get_column_dimension(db_url, schema) == 384
def test_dimension_change_blocked_with_data(self, dimension_test_schema):
@@ -284,12 +223,12 @@ class TestEmbeddingDimension:
insert_test_embedding(db_url, schema, 384)
assert get_row_count(db_url, schema) == 1
# Try to change dimension - should raise RuntimeError.
# Retry on transient OID errors from concurrent xdist schema drops.
_assert_raises_runtime_error_with_retry(
db_url, 768, schema,
expected_messages=["Cannot change embedding dimension", "1 rows with embeddings"],
)
# Try to change dimension - should raise error
with pytest.raises(RuntimeError) as exc_info:
ensure_embedding_dimension(db_url, 768, schema=schema)
assert "Cannot change embedding dimension" in str(exc_info.value)
assert "1 rows with embeddings" in str(exc_info.value)
# Dimension should be unchanged
assert get_column_dimension(db_url, schema) == 384
@@ -304,7 +243,7 @@ class TestEmbeddingDimension:
initial_dim = get_column_dimension(db_url, schema, table="mental_models")
assert initial_dim == 384, f"Expected 384, got {initial_dim}"
_ensure_embedding_dimension_with_retry(db_url, 384, schema=schema)
ensure_embedding_dimension(db_url, 384, schema=schema)
assert get_column_dimension(db_url, schema, table="mental_models") == 384
@@ -314,12 +253,12 @@ class TestEmbeddingDimension:
clear_mental_model_embeddings(db_url, schema)
_ensure_embedding_dimension_with_retry(db_url, 768, schema=schema)
ensure_embedding_dimension(db_url, 768, schema=schema)
assert get_column_dimension(db_url, schema, table="mental_models") == 768
# Change back for other tests
_ensure_embedding_dimension_with_retry(db_url, 384, schema=schema)
ensure_embedding_dimension(db_url, 384, schema=schema)
assert get_column_dimension(db_url, schema, table="mental_models") == 384
def test_mental_models_dimension_change_blocked_with_data(self, dimension_test_schema):
@@ -329,12 +268,11 @@ class TestEmbeddingDimension:
clear_mental_model_embeddings(db_url, schema)
insert_test_mental_model_embedding(db_url, schema, 384)
# Try to change dimension - should raise RuntimeError.
# Retry on transient OID errors from concurrent xdist schema drops.
_assert_raises_runtime_error_with_retry(
db_url, 768, schema,
expected_messages=["Cannot change embedding dimension", "mental_models"],
)
with pytest.raises(RuntimeError) as exc_info:
ensure_embedding_dimension(db_url, 768, schema=schema)
assert "Cannot change embedding dimension" in str(exc_info.value)
assert "mental_models" in str(exc_info.value)
assert get_column_dimension(db_url, schema, table="mental_models") == 384
@@ -1,88 +0,0 @@
"""Tests for daemonize() — subprocess.Popen re-exec instead of os.fork()."""
import sys
from unittest.mock import MagicMock, patch
import pytest
def test_daemonize_parent_reexecs_via_popen(monkeypatch, tmp_path):
"""Parent path: daemonize() must spawn a child via subprocess.Popen and exit."""
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.delenv("_HINDSIGHT_DAEMON_CHILD", raising=False)
monkeypatch.setattr(sys, "argv", ["hindsight-api", "--daemon", "--port", "9999"])
log_path = tmp_path / "daemon.log"
monkeypatch.setattr("hindsight_api.daemon.DAEMON_LOG_PATH", log_path)
captured: dict = {}
def fake_popen(cmd, **kwargs):
captured["cmd"] = cmd
captured["kwargs"] = kwargs
proc = MagicMock()
proc.pid = 99999
return proc
with (
patch("hindsight_api.daemon.subprocess.Popen", side_effect=fake_popen),
pytest.raises(SystemExit) as exc_info,
):
from hindsight_api.daemon import daemonize
daemonize()
assert exc_info.value.code == 0
# Verify child command does NOT contain --daemon
assert "--daemon" not in captured["cmd"]
# Verify it uses the module entry point
assert "-m" in captured["cmd"]
assert "hindsight_api.main" in captured["cmd"]
# Verify remaining args are preserved
assert "--port" in captured["cmd"]
assert "9999" in captured["cmd"]
# Verify env has the daemon child marker
env = captured["kwargs"]["env"]
assert env["_HINDSIGHT_DAEMON_CHILD"] == "1"
# Verify detach kwargs
kwargs = captured["kwargs"]
assert kwargs.get("start_new_session") is True
def test_daemonize_child_does_not_reexec(monkeypatch, tmp_path):
"""Child path: when _HINDSIGHT_DAEMON_CHILD=1, daemonize() does NOT call
Popen it only redirects stdio."""
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setenv("_HINDSIGHT_DAEMON_CHILD", "1")
log_path = tmp_path / "daemon.log"
monkeypatch.setattr("hindsight_api.daemon.DAEMON_LOG_PATH", log_path)
with (
patch("hindsight_api.daemon.subprocess.Popen") as mock_popen,
patch("hindsight_api.daemon._redirect_stdio_to_log") as mock_redirect,
):
from hindsight_api.daemon import daemonize
daemonize()
mock_popen.assert_not_called()
mock_redirect.assert_called_once()
def test_daemonize_windows_noop(monkeypatch, tmp_path):
"""On Windows, daemonize() just creates the log directory."""
monkeypatch.setattr(sys, "platform", "win32")
log_path = tmp_path / "subdir" / "daemon.log"
monkeypatch.setattr("hindsight_api.daemon.DAEMON_LOG_PATH", log_path)
with patch("hindsight_api.daemon.subprocess.Popen") as mock_popen:
from hindsight_api.daemon import daemonize
daemonize()
mock_popen.assert_not_called()
assert log_path.parent.exists()
@@ -17,10 +17,7 @@ from hindsight_api.engine.retain.entity_labels import (
EntityLabelsConfig,
LabelGroup,
LabelValue,
MapField,
build_labels_lookup,
build_labels_model,
is_label_entity,
parse_entity_labels,
)
@@ -1121,776 +1118,3 @@ async def test_retain_extracts_free_values_label(memory, request_context):
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_retain_extracts_map_type_entities(memory, request_context):
"""
End-to-end: retain content with a map-type entity_labels group.
Verify that structured entity fields are extracted as key:field:value entity strings.
"""
from hindsight_api.engine.memory_engine import fq_table
bank_id = f"test-labels-map-{uuid.uuid4().hex[:8]}"
try:
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Configure a map-type entity label
await memory._config_resolver.update_bank_config(
bank_id=bank_id,
updates={
"entity_labels": [
{
"key": "person",
"type": "map",
"description": "A person mentioned in the text",
"fields": {
"name": {"type": "text", "description": "Full name of the person"},
"role": {"type": "text", "description": "Job title or role"},
"organization": {"type": "text", "description": "Company or organization"},
},
}
],
"entities_allow_free_form": False, # map entities only
},
context=request_context,
)
unit_ids = await memory.retain_async(
bank_id=bank_id,
content=(
"Alice Johnson is a Senior Software Engineer at Google. "
"She leads the search infrastructure team and has been with the company for 5 years."
),
request_context=request_context,
)
assert len(unit_ids) > 0, "Should have extracted at least one fact"
async with memory._pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT e.canonical_name
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("entities")} e ON e.id = ue.entity_id
WHERE ue.unit_id = ANY($1::uuid[])
""",
[u for u in unit_ids],
)
entity_names = {r["canonical_name"].lower() for r in rows}
# Should have person:name:* entity
name_entities = {n for n in entity_names if n.startswith("person:name:")}
assert len(name_entities) > 0, (
f"Expected at least one person:name:* entity. Got: {entity_names}"
)
# Name should contain "alice" somewhere
assert any("alice" in n for n in name_entities), (
f"Expected person:name entity containing 'alice'. Got: {name_entities}"
)
# Should have person:organization:* entity mentioning google
org_entities = {n for n in entity_names if n.startswith("person:organization:")}
assert len(org_entities) > 0, (
f"Expected at least one person:organization:* entity. Got: {entity_names}"
)
assert any("google" in n for n in org_entities), (
f"Expected person:organization entity containing 'google'. Got: {org_entities}"
)
# In labels-only mode, free-form entities should be absent
non_person_entities = {n for n in entity_names if not n.startswith("person:")}
assert len(non_person_entities) == 0, (
f"Free-form entities should not appear in labels-only mode. Got: {non_person_entities}"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
# ─── map-type entity labels ──────────────────────────────────────────────────
def test_parse_entity_labels_map_type():
"""Map-type label group with fields is parsed correctly."""
raw = [
{
"key": "person",
"type": "map",
"description": "A person entity",
"fields": {
"name": {"type": "text", "description": "Full name"},
"role": {"type": "text", "description": "Job title"},
"organization": {"type": "text", "description": "Company"},
},
}
]
result = parse_entity_labels(raw)
assert result is not None
assert len(result.attributes) == 1
group = result.attributes[0]
assert group.key == "person"
assert group.type == "map"
assert len(group.fields) == 3
assert "name" in group.fields
assert group.fields["name"].description == "Full name"
def test_parse_entity_labels_map_type_dict_format():
"""Map-type label group via dict format."""
raw = {
"attributes": [
{
"key": "company",
"type": "map",
"fields": {
"name": {"type": "text"},
"industry": {"type": "text"},
},
}
]
}
result = parse_entity_labels(raw)
assert result is not None
assert result.attributes[0].type == "map"
assert len(result.attributes[0].fields) == 2
def test_build_labels_model_map_type():
"""Map-type groups produce list[MapModel] fields in the Labels model."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
description="A person",
fields={
"name": MapField(description="Full name"),
"role": MapField(description="Job title"),
},
),
]
)
model = build_labels_model(labels_cfg)
assert model is not None
schema = model.model_json_schema()
assert "person" in schema["properties"]
# Should be an array of objects
person_prop = schema["properties"]["person"]
assert person_prop["type"] == "array"
def test_build_labels_model_mixed_map_and_value():
"""Both map-type and value-type groups coexist in the same Labels model."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
description="A person",
fields={
"name": MapField(description="Full name"),
},
),
LabelGroup(
key="topic",
type="value",
values=[LabelValue(value="math"), LabelValue(value="science")],
),
]
)
model = build_labels_model(labels_cfg)
assert model is not None
schema = model.model_json_schema()
assert "person" in schema["properties"]
assert "topic" in schema["properties"]
def test_build_labels_model_map_type_no_fields():
"""Map-type group with no fields produces no field in the model."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(key="empty", type="map", fields={}),
]
)
model = build_labels_model(labels_cfg)
assert model is None
def test_build_labels_lookup_skips_map_type():
"""Map-type groups should not contribute to the two-level lookup set."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
fields={"name": MapField()},
),
LabelGroup(
key="topic",
type="value",
values=[LabelValue(value="math")],
),
]
)
lookup = build_labels_lookup(labels_cfg)
assert "topic:math" in lookup
# No map-type entries in the lookup
assert not any("person" in v for v in lookup)
def test_is_label_entity_map_type():
"""Three-level key:field:value strings are recognized as label entities."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
fields={
"name": MapField(),
"role": MapField(),
},
),
]
)
lookup = build_labels_lookup(labels_cfg)
assert is_label_entity("person:name:Alice", labels_cfg, lookup)
assert is_label_entity("person:role:Engineer", labels_cfg, lookup)
assert is_label_entity("Person:Name:Alice", labels_cfg, lookup) # case insensitive
assert not is_label_entity("person:unknown_field:value", labels_cfg, lookup)
assert not is_label_entity("person:Alice", labels_cfg, lookup) # two-level, not map
assert not is_label_entity("Alice", labels_cfg, lookup)
def test_build_labels_prompt_section_map_type():
"""Map-type groups appear in the STRUCTURED ENTITY TYPES prompt section."""
from hindsight_api.engine.retain.fact_extraction import _build_labels_prompt_section
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
description="A person entity",
fields={
"name": MapField(description="Full name"),
"role": MapField(description="Job title"),
},
),
]
)
result = _build_labels_prompt_section(labels_cfg)
assert "STRUCTURED ENTITY TYPES" in result
assert "person" in result
assert "name" in result
assert "role" in result
assert "Full name" in result
def test_build_labels_prompt_section_mixed():
"""Mixed map-type and value-type groups both appear in the prompt."""
from hindsight_api.engine.retain.fact_extraction import _build_labels_prompt_section
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="topic",
type="value",
description="Subject area",
values=[LabelValue(value="math")],
),
LabelGroup(
key="person",
type="map",
description="A person",
fields={"name": MapField(description="Full name")},
),
]
)
result = _build_labels_prompt_section(labels_cfg)
assert "CLASSIFICATION ATTRIBUTES" in result
assert "topic" in result
assert "STRUCTURED ENTITY TYPES" in result
assert "person" in result
def test_map_entity_post_processing():
"""Map-type labels are converted to key:field:value entity strings."""
from hindsight_api.engine.retain.fact_extraction import Entity, _extract_map_entities
fields = {
"name": MapField(type="text"),
"role": MapField(type="text"),
"organization": MapField(type="text"),
}
entity_obj = {"name": "Alice", "role": "Senior Engineer", "organization": "Acme Corp"}
validated: list[Entity] = []
existing: set[str] = set()
_extract_map_entities(entity_obj, fields, "person:", validated, existing)
texts = {e.text for e in validated}
assert "person:name:Alice" in texts
assert "person:role:Senior Engineer" in texts
assert "person:organization:Acme Corp" in texts
def test_map_entity_post_processing_null_fields_skipped():
"""Null/empty fields in map entities are skipped."""
from hindsight_api.engine.retain.fact_extraction import Entity, _extract_map_entities
fields = {
"name": MapField(type="text"),
"role": MapField(type="text"),
}
entity_obj = {"name": "Bob", "role": None}
validated: list[Entity] = []
existing: set[str] = set()
_extract_map_entities(entity_obj, fields, "person:", validated, existing)
texts = {e.text for e in validated}
assert "person:name:Bob" in texts
assert len(texts) == 1 # role was null, so only name
def test_map_entity_post_processing_multiple_entities():
"""Multiple map entities in a single fact produce separate entity strings."""
from hindsight_api.engine.retain.fact_extraction import Entity, _extract_map_entities
fields = {
"name": MapField(type="text"),
"role": MapField(type="text"),
}
validated: list[Entity] = []
existing: set[str] = set()
_extract_map_entities({"name": "Alice", "role": "Engineer"}, fields, "person:", validated, existing)
_extract_map_entities({"name": "Bob", "role": "Manager"}, fields, "person:", validated, existing)
texts = {e.text for e in validated}
assert "person:name:Alice" in texts
assert "person:role:Engineer" in texts
assert "person:name:Bob" in texts
assert "person:role:Manager" in texts
assert len(texts) == 4
# ─── recursive map-type entity labels ────────────────────────────────────────
def test_parse_entity_labels_recursive_map():
"""Nested map fields parse correctly."""
raw = [
{
"key": "person",
"type": "map",
"fields": {
"name": {"type": "text"},
"address": {
"type": "map",
"fields": {
"city": {"type": "text", "description": "City name"},
"country": {"type": "text"},
},
},
},
}
]
result = parse_entity_labels(raw)
assert result is not None
group = result.attributes[0]
assert group.fields["address"].type == "map"
assert "city" in group.fields["address"].fields
assert group.fields["address"].fields["city"].description == "City name"
def test_build_labels_model_recursive_map():
"""Nested map fields produce nested list[Model] in the schema."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
fields={
"name": MapField(type="text"),
"address": MapField(
type="map",
fields={
"city": MapField(type="text"),
"country": MapField(type="text"),
},
),
},
),
]
)
model = build_labels_model(labels_cfg)
assert model is not None
schema = model.model_json_schema()
person_prop = schema["properties"]["person"]
assert person_prop["type"] == "array"
def test_is_label_entity_recursive_map():
"""Deeply nested key:field:subfield:value strings are recognized."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
fields={
"name": MapField(type="text"),
"address": MapField(
type="map",
fields={
"city": MapField(type="text"),
"country": MapField(type="text"),
},
),
},
),
]
)
lookup = build_labels_lookup(labels_cfg)
assert is_label_entity("person:name:Alice", labels_cfg, lookup)
assert is_label_entity("person:address:city:New York", labels_cfg, lookup)
assert is_label_entity("person:address:country:US", labels_cfg, lookup)
assert not is_label_entity("person:address:zip:12345", labels_cfg, lookup)
assert not is_label_entity("person:address:New York", labels_cfg, lookup)
def test_recursive_map_post_processing():
"""Nested map entities produce deeply-joined key:field:subfield:value strings."""
from hindsight_api.engine.retain.fact_extraction import Entity, _extract_map_entities
fields = {
"name": MapField(type="text"),
"address": MapField(
type="map",
fields={
"city": MapField(type="text"),
"country": MapField(type="text"),
},
),
}
entity_obj = {
"name": "Alice",
"address": [{"city": "New York", "country": "US"}],
}
validated: list[Entity] = []
existing: set[str] = set()
_extract_map_entities(entity_obj, fields, "person:", validated, existing)
texts = {e.text for e in validated}
assert "person:name:Alice" in texts
assert "person:address:city:New York" in texts
assert "person:address:country:US" in texts
assert len(texts) == 3
def test_map_field_with_enum_values():
"""Map fields with value/multi-values types constrain extraction."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
fields={
"name": MapField(type="text"),
"department": MapField(
type="value",
values=[LabelValue(value="engineering"), LabelValue(value="sales")],
),
},
),
]
)
model = build_labels_model(labels_cfg)
assert model is not None
schema = model.model_json_schema()
# The model should exist and have the person field
assert "person" in schema["properties"]
def test_build_labels_prompt_section_recursive_map():
"""Nested map fields appear indented in the prompt."""
from hindsight_api.engine.retain.fact_extraction import _build_labels_prompt_section
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
description="A person",
fields={
"name": MapField(type="text", description="Full name"),
"address": MapField(
type="map",
description="Home address",
fields={
"city": MapField(type="text", description="City name"),
},
),
},
),
]
)
result = _build_labels_prompt_section(labels_cfg)
assert "name (text)" in result
assert "address (object)" in result
assert "city (text)" in result
# ─── map fields with value/multi-values types ────────────────────────────────
def test_map_field_value_post_processing():
"""Map field with type='value' extracts a single enum entity string."""
from hindsight_api.engine.retain.fact_extraction import Entity, _extract_map_entities
fields = {
"name": MapField(type="text"),
"department": MapField(
type="value",
values=[LabelValue(value="engineering"), LabelValue(value="sales")],
),
}
entity_obj = {"name": "Alice", "department": "engineering"}
validated: list[Entity] = []
existing: set[str] = set()
_extract_map_entities(entity_obj, fields, "person:", validated, existing)
texts = {e.text for e in validated}
assert "person:name:Alice" in texts
assert "person:department:engineering" in texts
assert len(texts) == 2
def test_map_field_multi_values_post_processing():
"""Map field with type='multi-values' extracts one entity per value."""
from hindsight_api.engine.retain.fact_extraction import Entity, _extract_map_entities
fields = {
"name": MapField(type="text"),
"skills": MapField(
type="multi-values",
values=[LabelValue(value="python"), LabelValue(value="go"), LabelValue(value="rust")],
),
}
entity_obj = {"name": "Alice", "skills": ["python", "rust"]}
validated: list[Entity] = []
existing: set[str] = set()
_extract_map_entities(entity_obj, fields, "person:", validated, existing)
texts = {e.text for e in validated}
assert "person:name:Alice" in texts
assert "person:skills:python" in texts
assert "person:skills:rust" in texts
assert "person:skills:go" not in texts
assert len(texts) == 3
def test_map_field_multi_values_null_skipped():
"""Null/sentinel values in multi-values are skipped."""
from hindsight_api.engine.retain.fact_extraction import Entity, _extract_map_entities
fields = {
"tags": MapField(type="multi-values"),
}
entity_obj = {"tags": ["valid", "none", "null", "", " ", "n/a", "also_valid"]}
validated: list[Entity] = []
existing: set[str] = set()
_extract_map_entities(entity_obj, fields, "item:", validated, existing)
texts = {e.text for e in validated}
assert "item:tags:valid" in texts
assert "item:tags:also_valid" in texts
assert len(texts) == 2
def test_nested_map_with_enum_fields_post_processing():
"""Nested map containing value/multi-values fields produces correct paths."""
from hindsight_api.engine.retain.fact_extraction import Entity, _extract_map_entities
fields = {
"name": MapField(type="text"),
"job": MapField(
type="map",
fields={
"title": MapField(type="text"),
"level": MapField(
type="value",
values=[LabelValue(value="junior"), LabelValue(value="senior")],
),
"languages": MapField(
type="multi-values",
values=[LabelValue(value="python"), LabelValue(value="java")],
),
},
),
}
entity_obj = {
"name": "Bob",
"job": [{"title": "Engineer", "level": "senior", "languages": ["python", "java"]}],
}
validated: list[Entity] = []
existing: set[str] = set()
_extract_map_entities(entity_obj, fields, "person:", validated, existing)
texts = {e.text for e in validated}
assert "person:name:Bob" in texts
assert "person:job:title:Engineer" in texts
assert "person:job:level:senior" in texts
assert "person:job:languages:python" in texts
assert "person:job:languages:java" in texts
assert len(texts) == 5
def test_is_label_entity_map_with_enum_fields():
"""Entity strings from map fields with value/multi-values types are recognized."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
fields={
"name": MapField(type="text"),
"department": MapField(
type="value",
values=[LabelValue(value="engineering")],
),
"skills": MapField(type="multi-values"),
},
),
]
)
lookup = build_labels_lookup(labels_cfg)
assert is_label_entity("person:name:Alice", labels_cfg, lookup)
assert is_label_entity("person:department:engineering", labels_cfg, lookup)
assert is_label_entity("person:skills:python", labels_cfg, lookup)
assert not is_label_entity("person:unknown:value", labels_cfg, lookup)
def test_is_label_entity_nested_map_with_enum():
"""Deeply nested paths with value/multi-values fields are recognized."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
fields={
"job": MapField(
type="map",
fields={
"level": MapField(type="value"),
"languages": MapField(type="multi-values"),
},
),
},
),
]
)
lookup = build_labels_lookup(labels_cfg)
assert is_label_entity("person:job:level:senior", labels_cfg, lookup)
assert is_label_entity("person:job:languages:python", labels_cfg, lookup)
assert not is_label_entity("person:job:salary:100k", labels_cfg, lookup)
def test_map_field_enum_schema_generation():
"""Map fields with value/multi-values generate correct JSON schema constraints."""
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
fields={
"name": MapField(type="text"),
"department": MapField(
type="value",
values=[LabelValue(value="engineering"), LabelValue(value="sales")],
),
"skills": MapField(
type="multi-values",
values=[LabelValue(value="python"), LabelValue(value="go")],
),
},
),
]
)
model = build_labels_model(labels_cfg)
assert model is not None
schema = model.model_json_schema()
# Resolve $defs to find the person entity schema
person_ref = schema["properties"]["person"]["items"]
if "$ref" in person_ref:
ref_name = person_ref["$ref"].split("/")[-1]
person_schema = schema["$defs"][ref_name]
else:
person_schema = person_ref
props = person_schema["properties"]
# department should be an enum
assert "department" in props
assert "enum" in props["department"] or "anyOf" in props["department"]
# skills should be an array
assert "skills" in props
assert props["skills"]["type"] == "array"
def test_prompt_section_map_with_all_field_types():
"""Prompt includes correct type hints for value/multi-values/map fields."""
from hindsight_api.engine.retain.fact_extraction import _build_labels_prompt_section
labels_cfg = EntityLabelsConfig(
attributes=[
LabelGroup(
key="person",
type="map",
description="A person",
fields={
"name": MapField(type="text", description="Full name"),
"department": MapField(
type="value",
description="Department",
values=[LabelValue(value="eng"), LabelValue(value="sales")],
),
"skills": MapField(
type="multi-values",
description="Skills list",
values=[LabelValue(value="python"), LabelValue(value="go")],
),
"address": MapField(
type="map",
description="Home address",
fields={"city": MapField(type="text")},
),
},
),
]
)
result = _build_labels_prompt_section(labels_cfg)
assert "name (text)" in result
assert "one of: eng, sales" in result
assert "multi-values: python, go" in result
assert "address (object)" in result
assert "city (text)" in result
def test_duplicate_entity_strings_deduplicated():
"""Same entity string from multiple nested objects is only added once."""
from hindsight_api.engine.retain.fact_extraction import Entity, _extract_map_entities
fields = {
"name": MapField(type="text"),
}
# Two entities with the same name
validated: list[Entity] = []
existing: set[str] = set()
_extract_map_entities({"name": "Alice"}, fields, "person:", validated, existing)
_extract_map_entities({"name": "Alice"}, fields, "person:", validated, existing)
texts = [e.text for e in validated]
assert texts == ["person:name:Alice"] # only once
@@ -326,88 +326,3 @@ class TestOracleFuzzyEntityResolution:
# The candidate IDs should be passed as bind parameter
cooc_bind_args = mock_conn.fetch.call_args_list[1].args[1:]
assert "eid-1" in cooc_bind_args[0], "Co-occurrence query must receive candidate IDs"
@pytest.mark.asyncio
async def test_link_units_carries_event_date_into_cooccurrences(pg0_db_url):
"""
`link_units_to_entities_batch` must propagate each unit's event_date onto the
accumulated _CooccurrencePair entries, so flush_pending_stats() stamps
entity_cooccurrences.last_cooccurred with the event time instead of "now".
This protects banks that were backfilled from another memory system
without it, every pair collapses to the import moment and the UI's entity
graph recency heat loses the underlying knowledge timeline.
"""
resolved_url = await resolve_database_url(pg0_db_url)
backend = create_database_backend("postgresql")
await backend.initialize(resolved_url, min_size=1, max_size=2, command_timeout=30)
bank_id = f"test-cooccurrence-evt-{uuid.uuid4().hex[:8]}"
resolver = EntityResolver(pool=backend, entity_lookup="full")
historical = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
try:
async with backend.acquire() as conn:
unit_id = await conn.fetchval(
"""
INSERT INTO memory_units
(bank_id, text, fact_type, mentioned_at, occurred_start, created_at)
VALUES ($1, 'paired-entity unit', 'experience', $2, $2, now())
RETURNING id
""",
bank_id,
historical,
)
e1 = await conn.fetchval(
"INSERT INTO entities (bank_id, canonical_name, first_seen, last_seen, mention_count) "
"VALUES ($1, 'alpha', $2, $2, 1) RETURNING id",
bank_id,
historical,
)
e2 = await conn.fetchval(
"INSERT INTO entities (bank_id, canonical_name, first_seen, last_seen, mention_count) "
"VALUES ($1, 'beta', $2, $2, 1) RETURNING id",
bank_id,
historical,
)
await resolver.link_units_to_entities_batch(
[(str(unit_id), str(e1), historical), (str(unit_id), str(e2), historical)],
conn=conn,
)
# Flush accumulates to entity_cooccurrences on a fresh connection, as
# the production flush runs post-transaction to avoid lock contention.
await resolver.flush_pending_stats()
async with backend.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT last_cooccurred
FROM entity_cooccurrences
WHERE entity_id_1 = LEAST($1::uuid, $2::uuid)
AND entity_id_2 = GREATEST($1::uuid, $2::uuid)
""",
e1,
e2,
)
assert row is not None, "co-occurrence row should exist"
assert row["last_cooccurred"] == historical, (
f"expected last_cooccurred == {historical}, got {row['last_cooccurred']}"
)
finally:
async with backend.acquire() as conn:
await conn.execute(
"DELETE FROM unit_entities WHERE unit_id IN (SELECT id FROM memory_units WHERE bank_id = $1)", bank_id
)
await conn.execute(
"DELETE FROM entity_cooccurrences WHERE entity_id_1 IN "
"(SELECT id FROM entities WHERE bank_id = $1) "
"OR entity_id_2 IN "
"(SELECT id FROM entities WHERE bank_id = $1)",
bank_id,
)
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
await backend.shutdown()
@@ -305,7 +305,6 @@ Family is the most important thing to her.
f"Found: {found_evaluative}"
)
@pytest.mark.hs_llm_mat
@pytest.mark.asyncio
async def test_comprehensive_multi_dimension(self):
"""Test a realistic scenario with multiple dimensions in one fact."""
@@ -337,21 +336,7 @@ I prefer presenting in person rather than virtually because I can read the room
has_emotional = any(term in all_facts_text for term in [
"thrilled", "positive feedback", "positive", "feedback", "enthusiastic"
])
# Check preference - should capture the in-person vs virtual preference
has_preference = any(term in all_facts_text for term in [
"prefer", "rather than", "in person", "in-person", "virtually",
"read the room", "face-to-face", "face to face", "remote",
])
# MAT bar: at least one of emotional or preferential must be preserved.
# Smaller models (e.g. nova-2-lite) may compress both sentences into a
# single fact that only captures one dimension — that's acceptable for
# a minimum-acceptance test.
assert has_emotional or has_preference, (
f"Should preserve at least one of emotional or preferential dimension. "
f"Extracted facts: {all_facts_text}"
)
assert has_emotional, "Should preserve emotional dimension"
# Check no vague temporal terms
prohibited_terms = ["recently", "soon", "lately"]
@@ -359,6 +344,12 @@ I prefer presenting in person rather than virtually because I can read the room
assert len(found_prohibited) == 0, \
f"Should NOT use vague temporal terms. Found: {found_prohibited}"
# Check preference - should capture the in-person vs virtual preference
has_preference = any(term in all_facts_text for term in [
"prefer", "rather than", "in person", "virtually", "read the room"
])
assert has_preference, "Should preserve preferential dimension"
# =============================================================================
# TEMPORAL CONVERSION TESTS
@@ -158,14 +158,6 @@ async def test_full_api_workflow(api_client, test_bank_id):
assert "total_nodes" in stats
assert stats["total_nodes"] > 0
# Verify bank list returns stats (fact_count, last_document_at)
response = await api_client.get("/v1/default/banks")
assert response.status_code == 200
banks_after = response.json()["banks"]
our_bank = next(b for b in banks_after if b["bank_id"] == test_bank_id)
assert our_bank["fact_count"] > 0, "fact_count should reflect retained memories"
assert our_bank["last_document_at"] is not None, "last_document_at should be set after retain"
# List memory units
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/memories/list",
+348 -108
View File
@@ -1,37 +1,60 @@
"""
LLM Minimum Acceptance Tests provider API surface.
Test LLM provider with different models using actual Hindsight memory operations.
Validates that a given LLM provider/model works correctly with Hindsight's
low-level LLM API methods: plain text, structured output, and tool calling.
The provider/model under test comes from HINDSIGHT_API_LLM_PROVIDER /
HINDSIGHT_API_LLM_MODEL env vars, which are set by the CI matrix in the
test-api-llm-acceptance job.
These tests are excluded from the regular test-api CI job via the
hs_llm_mat marker.
Tests validate that providers work correctly with:
1. Retain (memory ingestion with fact extraction)
2. Reflect (memory retrieval with tool calling)
3. Mental models (consolidated knowledge generation)
"""
import os
from datetime import datetime
import pytest
from hindsight_api.engine.llm_wrapper import LLMProvider
from hindsight_api.engine.utils import extract_facts
from hindsight_api.engine.search.think_utils import reflect
pytestmark = pytest.mark.hs_llm_mat
_PROVIDER = os.environ.get("HINDSIGHT_API_LLM_PROVIDER", "")
_MODEL = os.environ.get("HINDSIGHT_API_LLM_MODEL", "")
# Model matrix: (provider, model)
MODEL_MATRIX = [
# OpenAI models
("openai", "gpt-4o-mini"),
("openai", "gpt-4.1-mini"),
("openai", "gpt-4.1-nano"),
("openai", "gpt-5-mini"),
("openai", "gpt-5-nano"),
("openai", "gpt-5"),
("openai", "gpt-5.2"),
# Anthropic models
("anthropic", "claude-sonnet-4-20250514"),
("anthropic", "claude-opus-4-5-20251101"),
("anthropic", "claude-haiku-4-20250514"),
# Groq models
("groq", "openai/gpt-oss-120b"),
("groq", "openai/gpt-oss-20b"),
# DeepSeek models
("deepseek", "deepseek-v4-flash"),
("deepseek", "deepseek-chat"),
# Gemini models
("gemini", "gemini-2.5-flash"),
("gemini", "gemini-2.5-flash-lite"),
("gemini", "gemini-3.1-pro-preview"),
("gemini", "gemini-3.1-flash-lite-preview"),
# Ollama models (local)
("ollama", "gemma3:12b"),
("ollama", "gemma3:1b"),
# Claude Code (uses Claude Agent SDK with Claude models)
("claude-code", "claude-sonnet-4-20250514"),
# OpenAI Codex (uses MCP with Codex-specific models)
("openai-codex", "gpt-5.4-mini"),
# Bedrock models (via LiteLLM)
("bedrock", "us.amazon.nova-2-lite-v1:0"),
# Mock provider (for testing)
("mock", "mock"),
]
def _get_api_key() -> str:
"""Get API key from HINDSIGHT_API_LLM_API_KEY (CI) or provider-specific env var."""
key = os.environ.get("HINDSIGHT_API_LLM_API_KEY", "")
if key:
return key
# Fallback to provider-specific env vars for local dev
def get_api_key_for_provider(provider: str) -> str | None:
"""Get API key for provider from environment variables."""
provider_key_map = {
"openai": "OPENAI_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
@@ -39,24 +62,50 @@ def _get_api_key() -> str:
"gemini": "GEMINI_API_KEY",
"deepseek": "DEEPSEEK_API_KEY",
}
env_var = provider_key_map.get(_PROVIDER, "")
return os.environ.get(env_var, "") if env_var else ""
env_var = provider_key_map.get(provider)
return os.getenv(env_var) if env_var else None
def _make_llm() -> LLMProvider:
return LLMProvider(
provider=_PROVIDER,
api_key=_get_api_key(),
base_url=os.environ.get("HINDSIGHT_API_LLM_BASE_URL", ""),
model=_MODEL,
)
def should_skip_provider(provider: str, model: str = "") -> tuple[bool, str]:
"""Check if provider should be skipped and return reason."""
# Never skip mock provider
if provider == "mock":
return False, ""
# Skip claude-code and openai-codex in CI (require local auth)
if os.getenv("CI") and provider in ("claude-code", "openai-codex"):
return True, f"{provider} not available in CI (requires local authentication)"
# Skip Ollama in CI (no models available)
if provider == "ollama" and os.getenv("CI"):
return True, "Ollama not available in CI"
# Skip Ollama gemma models (don't support tool calling)
if provider == "ollama" and "gemma" in model.lower():
return True, f"Ollama {model} does not support tool calling"
# Bedrock needs AWS credentials
if provider == "bedrock":
if not os.getenv("AWS_ACCESS_KEY_ID"):
return True, "No AWS credentials available (set AWS_ACCESS_KEY_ID)"
return False, ""
# Other providers need an API key
if provider not in ("ollama", "claude-code", "openai-codex", "mock"):
api_key = get_api_key_for_provider(provider)
if not api_key:
return True, f"No API key available (set {provider.upper()}_API_KEY)"
return False, ""
@pytest.mark.parametrize("provider,model", MODEL_MATRIX)
@pytest.mark.asyncio
@pytest.mark.timeout(300)
async def test_llm_api_methods():
@pytest.mark.timeout(300) # Increase timeout for slow models like groq gpt-oss-120b
async def test_llm_provider_api_methods(provider: str, model: str):
"""
Test all LLM API methods used by Hindsight at runtime.
This validates that the provider correctly implements the LLMInterface.
Tests:
1. verify_connection() - Connection verification
@@ -64,108 +113,184 @@ async def test_llm_api_methods():
3. call() with response_format - Structured output (used in fact extraction)
4. call_with_tools() - Tool calling (used in reflect agent)
"""
llm = _make_llm()
# Skip mock provider - it's a test stub, not a real LLM implementation
if provider == "mock":
pytest.skip("Mock provider is a test stub, not a real LLM")
should_skip, reason = should_skip_provider(provider, model)
if should_skip:
pytest.skip(f"Skipping {provider}/{model}: {reason}")
api_key = get_api_key_for_provider(provider)
llm = LLMProvider(
provider=provider,
api_key=api_key or "",
base_url="",
model=model,
)
print(f"\n{provider}/{model} - API methods test:")
# Test 1: verify_connection()
await llm.verify_connection()
try:
await llm.verify_connection()
print(" ✓ verify_connection()")
except Exception as e:
pytest.fail(f"{provider}/{model} verify_connection() failed: {e}")
# Test 2: call() with plain text
response = await llm.call(
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is 2+2? Answer in one word."},
],
max_completion_tokens=50,
)
assert response is not None, "call() returned None"
assert len(response) > 0, "call() returned empty string"
try:
response = await llm.call(
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is 2+2? Answer in one word."},
],
max_completion_tokens=50,
)
assert response is not None, "call() returned None"
assert len(response) > 0, "call() returned empty string"
print(f" ✓ call() plain text: {response[:50]}")
except Exception as e:
pytest.fail(f"{provider}/{model} call() plain text failed: {e}")
# Test 3: call() with response_format (structured output)
from pydantic import BaseModel
# Skip for models that don't support structured output
skip_structured_output = (provider == "groq" and "gpt-oss-120b" in model.lower())
if skip_structured_output:
print(f" ⊘ call() structured output: skipped (model doesn't support response_format)")
else:
try:
from pydantic import BaseModel
class TestResponse(BaseModel):
answer: str
confidence: str
class TestResponse(BaseModel):
answer: str
confidence: str
structured = await llm.call(
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is the capital of France?"},
],
response_format=TestResponse,
max_completion_tokens=100,
)
assert isinstance(structured, TestResponse), f"Expected TestResponse, got {type(structured)}"
assert structured.answer, "Structured output missing 'answer'"
assert structured.confidence, "Structured output missing 'confidence'"
response = await llm.call(
messages=[
{"role": "system", "content": "You are a math assistant."},
{"role": "user", "content": "What is the capital of France?"},
],
response_format=TestResponse,
max_completion_tokens=100,
)
assert isinstance(response, TestResponse), f"Expected TestResponse, got {type(response)}"
assert hasattr(response, "answer"), "Structured output missing 'answer' field"
assert hasattr(response, "confidence"), "Structured output missing 'confidence' field"
print(f" ✓ call() structured output: answer={response.answer}, confidence={response.confidence}")
except Exception as e:
pytest.fail(f"{provider}/{model} call() structured output failed: {e}")
# Test 4: call_with_tools() (tool calling)
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the weather for a location",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "City name"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
try:
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the weather for a location",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "City name"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["location"],
},
"required": ["location"],
},
},
}
]
}
]
result = await llm.call_with_tools(
messages=[
{"role": "system", "content": "You are a helpful assistant with access to tools."},
{"role": "user", "content": "What's the weather like in Paris?"},
],
tools=tools,
max_completion_tokens=500,
result = await llm.call_with_tools(
messages=[
{"role": "system", "content": "You are a helpful assistant with access to tools."},
{"role": "user", "content": "What's the weather like in Paris?"},
],
tools=tools,
max_completion_tokens=500, # Increased from 200 to give models enough space for tool calls
)
assert result is not None, "call_with_tools() returned None"
assert hasattr(result, "tool_calls"), "Result missing 'tool_calls' attribute"
# Nano models may hit token limits before making tool calls - that's acceptable
is_nano_model = "nano" in model.lower()
if is_nano_model and len(result.tool_calls) == 0:
# Check if it hit length limit (expected for nano models)
if hasattr(result, "finish_reason") and result.finish_reason == "length":
print(f" ✓ call_with_tools(): nano model hit token limit (expected)")
else:
pytest.fail(f"Nano model made 0 tool calls but didn't hit length limit (finish_reason={getattr(result, 'finish_reason', 'unknown')})")
else:
assert len(result.tool_calls) > 0, f"Expected at least 1 tool call, got {len(result.tool_calls)}"
# Verify tool call structure
tool_call = result.tool_calls[0]
assert hasattr(tool_call, "name"), "Tool call missing 'name'"
assert hasattr(tool_call, "arguments"), "Tool call missing 'arguments'"
assert tool_call.name == "get_weather", f"Expected 'get_weather', got '{tool_call.name}'"
assert "location" in tool_call.arguments, "Tool call arguments missing 'location'"
print(f" ✓ call_with_tools(): {tool_call.name}({tool_call.arguments})")
except Exception as e:
pytest.fail(f"{provider}/{model} call_with_tools() failed: {e}")
@pytest.mark.parametrize("provider,model", MODEL_MATRIX)
@pytest.mark.asyncio
@pytest.mark.timeout(600) # 600s: some providers (e.g., bedrock via litellm) need extra time for fact extraction
async def test_llm_provider_memory_operations(provider: str, model: str):
"""
Test LLM provider with actual memory operations: fact extraction and reflect.
All models must pass this test.
"""
# Skip mock provider - it's a test stub, not designed for real operations
if provider == "mock":
pytest.skip("Mock provider is a test stub, not designed for real operations")
should_skip, reason = should_skip_provider(provider, model)
if should_skip:
pytest.skip(f"Skipping {provider}/{model}: {reason}")
api_key = get_api_key_for_provider(provider)
llm = LLMProvider(
provider=provider,
api_key=api_key or "",
base_url="",
model=model,
)
assert result is not None, "call_with_tools() returned None"
assert hasattr(result, "tool_calls"), "Result missing 'tool_calls' attribute"
assert len(result.tool_calls) > 0, f"Expected at least 1 tool call, got {len(result.tool_calls)}"
tool_call = result.tool_calls[0]
assert tool_call.name == "get_weather", f"Expected 'get_weather', got '{tool_call.name}'"
assert "location" in tool_call.arguments, "Tool call arguments missing 'location'"
@pytest.mark.asyncio
@pytest.mark.timeout(600)
async def test_llm_memory_operations():
"""
Test fact extraction and reflect with the configured LLM provider.
"""
llm = _make_llm()
# Fact extraction (structured output)
# Test 1: Fact extraction (structured output)
test_text = """
User: I just got back from my trip to Paris last week. The Eiffel Tower was amazing!
Assistant: That sounds wonderful! How long were you there?
User: About 5 days. I also visited the Louvre and saw the Mona Lisa.
"""
event_date = datetime(2024, 12, 10)
facts, chunks = await extract_facts(
text=test_text,
event_date=datetime(2024, 12, 10),
event_date=event_date,
context="Travel conversation",
llm_config=llm,
)
assert facts is not None, "fact extraction returned None"
assert len(facts) > 0, "should extract at least one fact"
print(f"\n{provider}/{model} - Fact extraction:")
print(f" Extracted {len(facts)} facts from {len(chunks)} chunks")
for fact in facts:
assert fact.fact, "fact missing text"
assert fact.fact_type in ["world", "experience"], f"invalid fact_type: {fact.fact_type}"
print(f" - {fact.fact}")
# Reflect
assert facts is not None, f"{provider}/{model} fact extraction returned None"
assert len(facts) > 0, f"{provider}/{model} should extract at least one fact"
# Verify facts have required fields
for fact in facts:
assert fact.fact, f"{provider}/{model} fact missing text"
assert fact.fact_type in ["world", "experience"], f"{provider}/{model} invalid fact_type: {fact.fact_type}"
# Test 2: Reflect (actual reflect function)
response = await reflect(
llm_config=llm,
query="What was the highlight of my Paris trip?",
@@ -182,5 +307,120 @@ async def test_llm_memory_operations():
name="Traveler",
)
assert response is not None, "reflect returned None"
assert len(response) > 10, "reflect response too short"
print(f"\n{provider}/{model} - Reflect response:")
print(f" {response[:200]}...")
assert response is not None, f"{provider}/{model} reflect returned None"
assert len(response) > 10, f"{provider}/{model} reflect response too short"
@pytest.mark.parametrize("provider,model", [
("claude-code", "claude-sonnet-4-20250514"),
("openai-codex", "gpt-5.4-mini"),
])
@pytest.mark.asyncio
async def test_llm_provider_consolidation(memory_no_llm_verify, request_context, provider: str, model: str):
"""
Test LLM provider with consolidation (automatic mental model generation from observations).
This validates that the provider can generate synthesized knowledge from raw memories.
This test is limited to claude-code and codex since they're the critical providers
that needed tool calling fixes for reflect and consolidation operations.
"""
should_skip, reason = should_skip_provider(provider, model)
if should_skip:
pytest.skip(f"Skipping {provider}/{model}: {reason}")
# Use provider-specific LLM for this test
api_key = get_api_key_for_provider(provider)
memory_no_llm_verify._consolidation_llm = LLMProvider(
provider=provider,
api_key=api_key or "",
base_url="",
model=model,
)
# Also need retain LLM for ingesting data
memory_no_llm_verify._retain_llm = memory_no_llm_verify._consolidation_llm
test_bank_id = f"llm_test_consolidation_{provider}_{model}_{datetime.now().timestamp()}"
# Enable observations for this bank
from hindsight_api.config import _get_raw_config
config = _get_raw_config()
original_value = config.enable_observations
config.enable_observations = True
try:
# Retain memories to consolidate
test_content = """
Bob prefers functional programming with Rust and Haskell.
He emphasizes immutability and pure functions in code reviews.
Bob advocates for type safety and compile-time guarantees.
He avoids mutable state and prefers declarative code patterns.
"""
await memory_no_llm_verify.retain_async(
bank_id=test_bank_id,
content=test_content,
context="Team coding preferences",
event_date=datetime(2024, 12, 1),
request_context=request_context,
)
print(f"\n{provider}/{model} - Consolidation test:")
# Run consolidation to generate observations (mental models)
from hindsight_api.engine.consolidation.consolidator import run_consolidation_job
result = await run_consolidation_job(
memory_engine=memory_no_llm_verify,
bank_id=test_bank_id,
request_context=request_context,
)
print(f" Processed: {result.get('memories_processed', 0)} memories")
print(f" Created: {result.get('observations_created', 0)} observations")
print(f" Updated: {result.get('observations_updated', 0)} observations")
# Verify consolidation ran successfully
assert result["status"] in ["success", "no_new_memories"], f"{provider}/{model} consolidation failed"
# If observations were created, verify they contain relevant content
if result.get("observations_created", 0) > 0:
observations = await memory_no_llm_verify.list_mental_models_consolidated(
bank_id=test_bank_id,
request_context=request_context,
)
assert len(observations) > 0, f"{provider}/{model} consolidation created 0 observations"
# Check first observation contains relevant information
obs_content = observations[0].get("content", "").lower()
relevant_terms = ["bob", "functional", "rust", "immutab", "type"]
matches = [term for term in relevant_terms if term in obs_content]
print(f" Observation preview: {observations[0].get('content', '')[:200]}...")
print(f" Found {len(matches)} relevant terms: {matches}")
assert len(matches) >= 2, (
f"{provider}/{model} consolidated observation doesn't contain relevant info. "
f"Expected at least 2 of {relevant_terms}, found {len(matches)}: {matches}"
)
finally:
# Restore original config
config.enable_observations = original_value
# NOTE: The tests above validate the critical Hindsight operations:
#
# test_llm_provider_memory_operations (ALL providers):
# - Fact extraction (retain): tests structured output generation
# - Reflect: tests memory retrieval and reasoning (uses tool calling for claude-code/codex)
#
# test_llm_provider_consolidation (claude-code and codex only):
# - Consolidation: tests automatic mental model generation from observations
# - Requires MemoryEngine fixture with working LLM (from .env or env vars)
# - Run your local LLM server OR set HINDSIGHT_API_LLM_PROVIDER/API_KEY/MODEL env vars
#
# For full end-to-end integration tests using the HTTP API, see tests/test_http_api_integration.py
@@ -1,345 +0,0 @@
"""
Tests for the LiteLLM Router LLM provider config parsing, factory dispatch,
and the Router-backed call paths (plain text, structured output, tool calls,
retry on transient failure).
The provider is a thin pass-through to ``litellm.Router``. The chain config
shape mirrors LiteLLM's API; we don't translate model names or impose
fallbacks. See https://docs.litellm.ai/docs/routing.
"""
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import BaseModel
from hindsight_api.config import (
ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG,
ENV_LLM_LITELLMROUTER_CONFIG,
ENV_LLM_PROVIDER,
ENV_REFLECT_LLM_LITELLMROUTER_CONFIG,
ENV_RETAIN_LLM_LITELLMROUTER_CONFIG,
HindsightConfig,
_parse_llm_router_config,
)
from hindsight_api.engine.llm_wrapper import create_llm_provider
from hindsight_api.engine.providers.litellm_router_llm import LiteLLMRouterLLM
@pytest.fixture
def two_step_config() -> dict[str, Any]:
"""Raw LiteLLM Router config: two deployments wired for ordered fallback.
Hindsight always issues completions against ``model_name="default"``;
additional groups become fallback / load-balance pool members per the
user's ``fallbacks`` / ``routing_strategy`` settings.
"""
return {
"model_list": [
{
"model_name": "default",
"litellm_params": {
"model": "openai/MiniMax-M2.7",
"api_key": "sk-primary",
"api_base": "https://api.minimax.io/v1",
},
},
{
"model_name": "fallback",
"litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-fallback"},
},
],
"fallbacks": [{"default": ["fallback"]}],
"num_retries": 0,
}
@pytest.fixture
def mock_router_response() -> MagicMock:
response = MagicMock()
choice = MagicMock()
choice.message.content = "ok"
choice.message.tool_calls = None
choice.finish_reason = "stop"
response.choices = [choice]
response.usage.prompt_tokens = 12
response.usage.completion_tokens = 3
response._hidden_params = {"model": "openai/gpt-4o-mini"}
return response
# --- config parsing ----------------------------------------------------------
class TestParseRouterConfig:
def test_unset_returns_none(self, monkeypatch):
monkeypatch.delenv(ENV_LLM_LITELLMROUTER_CONFIG, raising=False)
assert _parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG) is None
def test_empty_string_returns_none(self, monkeypatch):
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, " ")
assert _parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG) is None
def test_valid_config_passes_through(self, monkeypatch, two_step_config):
"""Whatever the user provides round-trips verbatim — no translation."""
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, json.dumps(two_step_config))
assert _parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG) == two_step_config
def test_invalid_json(self, monkeypatch):
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, "{not json")
with pytest.raises(ValueError, match="invalid JSON"):
_parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG)
def test_no_shape_validation(self, monkeypatch):
"""We don't validate the shape — anything that parses as JSON gets passed through.
LiteLLM Router is authoritative for shape errors; we let them surface at
Router construction time rather than pre-validating.
"""
# A list, a string, an object with junk keys — all accepted by the parser.
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, json.dumps([{"hello": "world"}]))
assert _parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG) == [{"hello": "world"}]
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, json.dumps({"only": "garbage"}))
assert _parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG) == {"only": "garbage"}
class TestFromEnvLoadsConfig:
def test_loaded_when_provider_is_litellmrouter(self, monkeypatch, two_step_config):
monkeypatch.setenv(ENV_LLM_PROVIDER, "litellmrouter")
monkeypatch.setenv(ENV_LLM_LITELLMROUTER_CONFIG, json.dumps(two_step_config))
cfg = HindsightConfig.from_env()
assert cfg.llm_provider == "litellmrouter"
assert cfg.llm_litellmrouter_config == two_step_config
def test_unset_keeps_default_provider(self, monkeypatch):
monkeypatch.setenv(ENV_LLM_PROVIDER, "openai")
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "sk-primary")
monkeypatch.delenv(ENV_LLM_LITELLMROUTER_CONFIG, raising=False)
cfg = HindsightConfig.from_env()
assert cfg.llm_provider == "openai"
assert cfg.llm_litellmrouter_config is None
def test_per_op_configs_independent(self, monkeypatch):
"""Per-op env vars populate per-op fields without touching the default."""
retain_config = {
"model_list": [{"model_name": "r", "litellm_params": {"model": "openai/retain", "api_key": "rk"}}]
}
reflect_config = {
"model_list": [{"model_name": "f", "litellm_params": {"model": "anthropic/claude", "api_key": "ak"}}]
}
consol_config = {
"model_list": [{"model_name": "c", "litellm_params": {"model": "openai/consol", "api_key": "ck"}}]
}
monkeypatch.setenv(ENV_LLM_PROVIDER, "openai")
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "sk-primary")
monkeypatch.setenv(ENV_RETAIN_LLM_LITELLMROUTER_CONFIG, json.dumps(retain_config))
monkeypatch.setenv(ENV_REFLECT_LLM_LITELLMROUTER_CONFIG, json.dumps(reflect_config))
monkeypatch.setenv(ENV_CONSOLIDATION_LLM_LITELLMROUTER_CONFIG, json.dumps(consol_config))
cfg = HindsightConfig.from_env()
assert cfg.llm_litellmrouter_config is None
assert cfg.retain_llm_litellmrouter_config == retain_config
assert cfg.reflect_llm_litellmrouter_config == reflect_config
assert cfg.consolidation_llm_litellmrouter_config == consol_config
# --- factory dispatch --------------------------------------------------------
class TestFactoryDispatch:
def test_router_provider_requires_config(self):
with pytest.raises(ValueError, match="config object"):
create_llm_provider(
provider="litellmrouter",
api_key="",
base_url="",
model="unused",
reasoning_effort="low",
litellmrouter_config=None,
)
def test_router_provider_returns_router_impl(self, two_step_config):
with patch.dict("sys.modules", {"litellm": MagicMock()}):
with patch(
"hindsight_api.engine.providers.litellm_router_llm.LiteLLMRouterLLM.__init__",
return_value=None,
) as mock_init:
impl = create_llm_provider(
provider="litellmrouter",
api_key="",
base_url="",
model="unused",
reasoning_effort="low",
litellmrouter_config=two_step_config,
)
assert isinstance(impl, LiteLLMRouterLLM)
_, kwargs = mock_init.call_args
assert kwargs["config"] == two_step_config
# --- Router-backed call paths ------------------------------------------------
def _make_router_provider(config: dict[str, Any], mock_router: Any) -> LiteLLMRouterLLM:
"""Construct a LiteLLMRouterLLM with the inner Router replaced by a mock."""
fake_litellm = MagicMock()
fake_litellm.Router = MagicMock(return_value=mock_router)
with patch.dict("sys.modules", {"litellm": fake_litellm}):
# Bypass the heavy ctor chain by injecting state directly.
provider = LiteLLMRouterLLM.__new__(LiteLLMRouterLLM)
provider.provider = "litellmrouter"
provider.api_key = ""
provider.base_url = ""
provider.model = "unused"
provider.reasoning_effort = "low"
provider.timeout = 300.0
provider.config = config
provider._litellm = fake_litellm
provider._router = mock_router
provider._router_output_cap = None # tests that exercise the cap override this directly
return provider
class TestRouterCall:
@pytest.mark.asyncio
async def test_plain_text_call_targets_default_entrypoint(self, two_step_config, mock_router_response):
mock_router = MagicMock()
mock_router.acompletion = AsyncMock(return_value=mock_router_response)
provider = _make_router_provider(two_step_config, mock_router)
result = await provider.call(
messages=[{"role": "user", "content": "hi"}],
max_completion_tokens=50,
max_retries=0,
)
assert result == "ok"
# Hindsight always issues against model_name="default"; Router handles fallback,
# load-balancing, and routing strategy from there.
kwargs = mock_router.acompletion.await_args.kwargs
assert kwargs["model"] == "default"
@pytest.mark.asyncio
async def test_structured_output(self, two_step_config):
class MySchema(BaseModel):
answer: str
response = MagicMock()
choice = MagicMock()
choice.message.content = '{"answer": "42"}'
choice.message.tool_calls = None
choice.finish_reason = "stop"
response.choices = [choice]
response.usage.prompt_tokens = 5
response.usage.completion_tokens = 5
response._hidden_params = {"model": "openai/gpt-4o-mini"}
mock_router = MagicMock()
mock_router.acompletion = AsyncMock(return_value=response)
provider = _make_router_provider(two_step_config, mock_router)
result = await provider.call(
messages=[{"role": "user", "content": "q"}],
response_format=MySchema,
max_retries=0,
)
assert isinstance(result, MySchema)
assert result.answer == "42"
@pytest.mark.asyncio
async def test_retry_on_transient_then_success(self, two_step_config, mock_router_response):
mock_router = MagicMock()
# First call raises a 503-style error, second call returns ok.
mock_router.acompletion = AsyncMock(side_effect=[Exception("503 Service Unavailable"), mock_router_response])
provider = _make_router_provider(two_step_config, mock_router)
result = await provider.call(
messages=[{"role": "user", "content": "hi"}],
max_retries=2,
initial_backoff=0.0,
max_backoff=0.0,
)
assert result == "ok"
assert mock_router.acompletion.await_count == 2
@pytest.mark.asyncio
async def test_auth_error_does_not_retry(self, two_step_config):
mock_router = MagicMock()
mock_router.acompletion = AsyncMock(side_effect=Exception("401 Unauthorized: bad key"))
provider = _make_router_provider(two_step_config, mock_router)
with pytest.raises(Exception, match="401"):
await provider.call(
messages=[{"role": "user", "content": "hi"}],
max_retries=5,
initial_backoff=0.0,
)
assert mock_router.acompletion.await_count == 1
@pytest.mark.asyncio
async def test_caps_max_completion_tokens_to_litellm_registry(self, two_step_config, mock_router_response):
"""Cap max_completion_tokens to the most conservative deployment limit.
Hindsight's defaults (e.g. retain_max_completion_tokens=64000) target
high-capacity models. When a configured deployment has a smaller cap
(gpt-4.1-nano = 32768), the call would otherwise be rejected apply
the cap silently using LiteLLM's per-model registry.
"""
mock_router = MagicMock()
mock_router.acompletion = AsyncMock(return_value=mock_router_response)
provider = _make_router_provider(two_step_config, mock_router)
provider._router_output_cap = 32768 # what _compute_router_output_cap would yield
await provider.call(
messages=[{"role": "user", "content": "hi"}],
max_completion_tokens=64000, # over the cap
max_retries=0,
)
kwargs = mock_router.acompletion.await_args.kwargs
assert kwargs["max_completion_tokens"] == 32768
@pytest.mark.asyncio
async def test_no_cap_when_litellm_registry_has_no_data(self, two_step_config, mock_router_response):
"""If LiteLLM doesn't know any of the deployment models, pass the requested value through."""
mock_router = MagicMock()
mock_router.acompletion = AsyncMock(return_value=mock_router_response)
provider = _make_router_provider(two_step_config, mock_router)
provider._router_output_cap = None
await provider.call(
messages=[{"role": "user", "content": "hi"}],
max_completion_tokens=64000,
max_retries=0,
)
kwargs = mock_router.acompletion.await_args.kwargs
assert kwargs["max_completion_tokens"] == 64000
@pytest.mark.asyncio
async def test_call_with_tools(self, two_step_config):
response = MagicMock()
choice = MagicMock()
choice.message.content = None
tool_call = MagicMock()
tool_call.id = "call_1"
tool_call.function.name = "lookup"
tool_call.function.arguments = '{"q": "x"}'
choice.message.tool_calls = [tool_call]
choice.finish_reason = "tool_calls"
response.choices = [choice]
response.usage.prompt_tokens = 5
response.usage.completion_tokens = 2
response._hidden_params = {"model": "openai/gpt-4o-mini"}
mock_router = MagicMock()
mock_router.acompletion = AsyncMock(return_value=response)
provider = _make_router_provider(two_step_config, mock_router)
result = await provider.call_with_tools(
messages=[{"role": "user", "content": "use tool"}],
tools=[{"type": "function", "function": {"name": "lookup", "parameters": {}}}],
max_retries=0,
)
assert result.content is None
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "lookup"
assert result.tool_calls[0].arguments == {"q": "x"}
@@ -949,51 +949,6 @@ class TestRecallNewParams:
call_kwargs = mock_memory.recall_async.call_args.kwargs
assert call_kwargs["question_date"] == datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
async def test_recall_with_tag_groups_negative_filter(self, mock_memory):
"""tag_groups with NOT should pass through to engine after Pydantic validation."""
from hindsight_api.engine.search.tags import TagGroupNot
mcp = _make_mcp_server(mock_memory, {"recall"})
await _tools(mcp)["recall"].fn(
query="test",
tag_groups=[{"not": {"tags": ["closeout"], "match": "any_strict"}}],
)
call_kwargs = mock_memory.recall_async.call_args.kwargs
assert "tag_groups" in call_kwargs
assert len(call_kwargs["tag_groups"]) == 1
group = call_kwargs["tag_groups"][0]
assert isinstance(group, TagGroupNot)
assert group.filter.tags == ["closeout"]
async def test_recall_without_tag_groups_no_kwarg(self, mock_memory):
"""tag_groups omitted should not appear in engine kwargs."""
mcp = _make_mcp_server(mock_memory, {"recall"})
await _tools(mcp)["recall"].fn(query="test")
call_kwargs = mock_memory.recall_async.call_args.kwargs
assert "tag_groups" not in call_kwargs
async def test_recall_tags_and_tag_groups_mutually_exclusive(self, mock_memory):
"""Passing both tags and tag_groups returns an error and does not call engine."""
mcp = _make_mcp_server(mock_memory, {"recall"})
result = await _tools(mcp)["recall"].fn(
query="test",
tags=["project:x"],
tag_groups=[{"tags": ["closeout"], "match": "any_strict"}],
)
assert "mutually exclusive" in result
mock_memory.recall_async.assert_not_called()
async def test_recall_tag_groups_single_bank(self, mock_memory):
"""tag_groups should also work in single-bank mode."""
mcp = _make_mcp_server(mock_memory, {"recall"}, include_bank_id=False)
await _tools(mcp)["recall"].fn(
query="test",
tag_groups=[{"tags": ["scope:work"], "match": "all_strict"}],
)
call_kwargs = mock_memory.recall_async.call_args.kwargs
assert "tag_groups" in call_kwargs
assert len(call_kwargs["tag_groups"]) == 1
@pytest.mark.asyncio
class TestReflectNewParams:
@@ -1,48 +0,0 @@
from hindsight_api.api.http import MentalModelTrigger
from hindsight_api.engine.search.tags import TagGroupOr
def test_mental_model_trigger_model_dump_preserves_or_tag_group():
trigger = MentalModelTrigger.model_validate(
{
"tag_groups": [
{
"or": [
{"tags": ["ns:a"], "match": "all_strict"},
{"tags": ["ns:b"], "match": "all_strict"},
]
}
]
}
)
dumped = trigger.model_dump()
assert dumped["tag_groups"] == [
{
"or": [
{"tags": ["ns:a"], "match": "all_strict"},
{"tags": ["ns:b"], "match": "all_strict"},
]
}
]
def test_mental_model_trigger_or_tag_group_survives_storage_round_trip():
trigger = MentalModelTrigger.model_validate(
{
"tag_groups": [
{
"or": [
{"tags": ["ns:a"], "match": "all_strict"},
{"tags": ["ns:b"], "match": "all_strict"},
]
}
]
}
)
round_tripped = MentalModelTrigger.model_validate(trigger.model_dump())
assert isinstance(round_tripped.tag_groups[0], TagGroupOr)
assert round_tripped.model_dump()["tag_groups"] == trigger.model_dump()["tag_groups"]
@@ -1,5 +1,5 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from types import SimpleNamespace
import pytest
from pydantic import BaseModel
@@ -52,24 +52,6 @@ async def test_json_object_call_adds_json_hint_to_user_message():
assert sent_messages[0]["content"].startswith("Return valid json only.")
@pytest.mark.asyncio
async def test_json_object_call_strips_gemma_thought_tags_before_parsing():
llm = _llm()
create = AsyncMock(
return_value=_response(content='<thought>\nI should return a compact JSON object.\n</thought>\n{"ok": true}')
)
llm._client.chat.completions.create = create
with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"):
result = await llm.call(
messages=[{"role": "user", "content": "Return whether this worked."}],
response_format=SimpleJsonResponse,
max_retries=0,
)
assert result.ok is True
@pytest.mark.asyncio
async def test_error_payload_with_no_choices_raises_clear_provider_error_without_retry():
llm = _llm()
@@ -1,80 +0,0 @@
"""Tests for the opencode-go OpenAI-compatible LLM provider."""
import pytest
def test_opencode_go_config_has_expected_default_model(monkeypatch):
"""HindsightConfig should default opencode-go to the DeepSeek v4 flash model."""
from hindsight_api.config import PROVIDER_DEFAULT_MODELS, HindsightConfig, clear_config_cache
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "opencode-go")
monkeypatch.delenv("HINDSIGHT_API_LLM_MODEL", raising=False)
clear_config_cache()
try:
assert PROVIDER_DEFAULT_MODELS["opencode-go"] == "deepseek-v4-flash"
config = HindsightConfig.from_env()
assert config.llm_provider == "opencode-go"
assert config.llm_model == "deepseek-v4-flash"
finally:
clear_config_cache()
def test_opencode_go_llm_provider_from_env_has_expected_default_model(monkeypatch):
"""LLMProvider.from_env should use the opencode-go provider default model."""
from hindsight_api.config import clear_config_cache
from hindsight_api.engine.llm_wrapper import LLMProvider
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "opencode-go")
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "test-key")
monkeypatch.delenv("HINDSIGHT_API_LLM_MODEL", raising=False)
monkeypatch.delenv("HINDSIGHT_API_LLM_BASE_URL", raising=False)
clear_config_cache()
try:
llm = LLMProvider.from_env()
assert llm.provider == "opencode-go"
assert llm.model == "deepseek-v4-flash"
assert llm.base_url == "https://opencode.ai/zen/go/v1"
finally:
clear_config_cache()
def test_opencode_go_requires_api_key_like_zai():
"""opencode-go is a cloud provider and should require an API key."""
from hindsight_api.engine.llm_wrapper import requires_api_key
assert requires_api_key("opencode-go") is True
def test_opencode_go_uses_openai_compatible_provider_with_default_base_url():
"""The provider factory should route opencode-go to OpenAICompatibleLLM."""
from hindsight_api.engine.llm_wrapper import LLMProvider
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
llm = LLMProvider(
provider="opencode-go",
api_key="test-key",
base_url="",
model="deepseek-v4-flash",
)
assert llm.provider == "opencode-go"
assert llm.model == "deepseek-v4-flash"
assert llm.base_url == "https://opencode.ai/zen/go/v1"
assert not llm.base_url.endswith("/")
assert isinstance(llm._provider_impl, OpenAICompatibleLLM)
assert llm._provider_impl.base_url == "https://opencode.ai/zen/go/v1"
def test_opencode_go_rejects_missing_api_key():
"""opencode-go should fail fast without an API key, matching zai behavior."""
from hindsight_api.engine.llm_wrapper import LLMProvider
with pytest.raises(ValueError, match="API key is required for opencode-go"):
LLMProvider(
provider="opencode-go",
api_key="",
base_url="",
model="deepseek-v4-flash",
)
@@ -10,12 +10,13 @@ retry hits the same unhandled error so the entire retry budget is wasted.
See https://github.com/vectorize-io/hindsight/issues/1334.
"""
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import BaseModel
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM, ProviderResponseError
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
class _Response(BaseModel):
@@ -32,38 +33,25 @@ def _make_llm() -> OpenAICompatibleLLM:
def _make_chat_response(content: str | None) -> MagicMock:
"""Build a mock that matches the shape expected by _first_choice_or_error.
Key fields that must be explicitly set (not left as auto-MagicMock):
- response.error = None (otherwise truthy MagicMock triggers error path)
- response.model_dump() (returns dict without 'error' key)
- choice.message.tool_calls/refusal (otherwise truthy MagicMock in error msg)
"""
choice = MagicMock()
choice.finish_reason = "stop"
choice.message.content = content
choice.message.tool_calls = None
choice.message.refusal = None
response = MagicMock()
response.error = None
response.model_dump.return_value = {}
response.usage.prompt_tokens = 10
response.usage.completion_tokens = 0 if content is None else 5
response.usage.total_tokens = 10 if content is None else 15
response.choices = [choice]
response.choices[0].finish_reason = "stop"
response.choices[0].message.content = content
response.choices[0].message.tool_calls = None
return response
@pytest.mark.asyncio
async def test_null_content_raises_after_retries_exhausted():
"""All retries return null content -> ProviderResponseError, not TypeError."""
"""All retries return null content -> JSONDecodeError, not TypeError."""
llm = _make_llm()
with patch.object(llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
mock_create.return_value = _make_chat_response(None)
with pytest.raises(ProviderResponseError, match="empty message content"):
with pytest.raises(json.JSONDecodeError):
await llm.call(
messages=[{"role": "user", "content": "extract facts"}],
response_format=_Response,
@@ -1,135 +0,0 @@
"""Tests for the optional read-only backend for recall queries."""
from __future__ import annotations
import pytest
import pytest_asyncio
from hindsight_api import MemoryEngine
from hindsight_api.engine.task_backend import SyncTaskBackend
def _make_engine(pg0_db_url: str, embeddings, cross_encoder, query_analyzer) -> MemoryEngine:
"""Build a MemoryEngine for a single test. Tiny pool, no migrations,
SyncTaskBackend so async tasks resolve inline. Mirrors conftest's
``memory`` fixture but lets each test build its own with custom env.
"""
return MemoryEngine(
db_url=pg0_db_url,
memory_llm_provider="none", # No LLM calls in these tests
memory_llm_api_key="unused",
memory_llm_model="unused",
embeddings=embeddings,
cross_encoder=cross_encoder,
query_analyzer=query_analyzer,
pool_min_size=1,
pool_max_size=2,
run_migrations=False,
task_backend=SyncTaskBackend(),
)
@pytest_asyncio.fixture
async def engine_no_read_url(monkeypatch, pg0_db_url, embeddings, cross_encoder, query_analyzer):
"""Engine built with READ_DATABASE_URL explicitly unset."""
from hindsight_api.config import clear_config_cache
monkeypatch.delenv("HINDSIGHT_API_READ_DATABASE_URL", raising=False)
clear_config_cache()
mem = _make_engine(pg0_db_url, embeddings, cross_encoder, query_analyzer)
await mem.initialize()
yield mem
try:
await mem.close()
except Exception:
pass
clear_config_cache()
@pytest_asyncio.fixture
async def engine_with_read_url(monkeypatch, pg0_db_url, embeddings, cross_encoder, query_analyzer):
"""Engine built with READ_DATABASE_URL set to the same DB. The engine
can't tell it's the same server it just sees a second URL and opens
a second backend, which is what we want to verify.
"""
from hindsight_api.config import clear_config_cache
monkeypatch.setenv("HINDSIGHT_API_READ_DATABASE_URL", pg0_db_url)
clear_config_cache()
mem = _make_engine(pg0_db_url, embeddings, cross_encoder, query_analyzer)
await mem.initialize()
yield mem
try:
await mem.close()
except Exception:
pass
clear_config_cache()
@pytest.mark.asyncio
async def test_read_backend_aliases_primary_when_url_unset(engine_no_read_url):
"""Without HINDSIGHT_API_READ_DATABASE_URL, _read_backend is _backend.
This is the back-compat invariant every call site that uses
_get_read_backend() resolves to the same object _get_backend() returns,
so nothing observable changes.
"""
assert engine_no_read_url._read_backend is engine_no_read_url._backend
@pytest.mark.asyncio
async def test_read_backend_is_separate_instance_when_url_set(engine_with_read_url):
"""With HINDSIGHT_API_READ_DATABASE_URL set, a SECOND backend object is
created. Even if both URLs point at the same DB (as in this test), the
two backends own independent connection pools connections taken from
one don't drain the other, and shutting one down doesn't close the
other's pool.
"""
primary = engine_with_read_url._backend
read = engine_with_read_url._read_backend
assert read is not primary
# Both backends are independently initialized (each owns a pool)
assert primary.get_pool() is not None
assert read.get_pool() is not None
assert primary.get_pool() is not read.get_pool()
@pytest.mark.asyncio
async def test_get_read_backend_returns_read_backend(engine_with_read_url):
"""The accessor used by recall (`_get_read_backend`) returns the dedicated
read backend, not the primary. Without this, the env var would have no
effect.
"""
backend = await engine_with_read_url._get_read_backend()
assert backend is engine_with_read_url._read_backend
assert backend is not engine_with_read_url._backend
@pytest.mark.asyncio
async def test_get_read_backend_returns_primary_when_unset(engine_no_read_url):
"""The accessor falls through to the primary when no read URL is set,
so callers don't need to handle a None case.
"""
backend = await engine_no_read_url._get_read_backend()
assert backend is engine_no_read_url._backend
@pytest.mark.asyncio
async def test_close_terminates_distinct_read_backend(engine_with_read_url):
"""When the read backend is distinct, close() must shut it down too,
not just the primary. Otherwise we leak the read pool when the engine
is recycled (e.g. across pytest sessions or in app shutdown).
"""
primary_before = engine_with_read_url._backend
read_before = engine_with_read_url._read_backend
assert read_before is not primary_before
await engine_with_read_url.close()
# Both backends should be cleared on close. The exact post-close state
# is "primary _backend cleared, _read_backend cleared". The shutdown
# method on the read backend is called — we verify by checking the
# engine no longer references either.
assert engine_with_read_url._backend is None
assert engine_with_read_url._read_backend is None
@@ -1,56 +0,0 @@
"""Tests that recall_async surfaces a non-opaque error message when the
underlying retrieval pipeline raises an exception with empty __str__.
Regression for issue #1384: ``raise Exception(f"Failed to search memories: ...{e}")``
collapsed to ``Failed to search memories: `` for any exception whose __str__()
returns blank, dropping the original class name and traceback chain.
"""
from unittest.mock import patch
import pytest
from hindsight_api import MemoryEngine, RequestContext
RC = RequestContext(tenant_id="default")
class _SilentError(Exception):
"""Mimics asyncpg.exceptions.ConnectionDoesNotExistError() and similar
exceptions whose __str__() returns blank when raised with no args."""
async def _raise_silent(*_args, **_kwargs):
raise _SilentError()
async def test_recall_async_error_preserves_original(memory_no_llm_verify: MemoryEngine):
engine = memory_no_llm_verify
bank_id = "test-error-propagation"
await engine.get_bank_profile(bank_id, request_context=RC)
try:
with patch(
"hindsight_api.engine.memory_engine.embedding_utils.generate_embeddings_batch",
side_effect=_raise_silent,
):
with pytest.raises(RuntimeError) as excinfo:
await engine.recall_async(
bank_id=bank_id,
query="anything",
request_context=RC,
)
# The wrapping message must include the original exception class name —
# the symptom in #1384 was an empty trailer like "Failed to search memories: ".
message = str(excinfo.value)
assert "Failed to search memories" in message
assert "_SilentError" in message, (
f"wrapper message dropped the original exception class: {message!r}"
)
# `from e` chain must be preserved so worker logs / debuggers can walk
# back to the real cause.
assert isinstance(excinfo.value.__cause__, _SilentError)
finally:
await engine.delete_bank(bank_id, request_context=RC)
@@ -1,214 +0,0 @@
"""Recall projects entities for observations through source_memory_ids.
Observations don't carry rows in `unit_entities`; their entity association
lives transitively via `memory_units.source_memory_ids`. The per-memory
endpoint (`get_memory_unit`) follows that chain, but recall used to query
`unit_entities` directly and silently dropped entities for every observation
result, even when `include_entities=True` was set.
This test seeds an observation linked through `source_memory_ids` to a fact
with entities, runs an observation-only recall, and asserts both the
per-result `entities` field and the top-level aggregate map carry the
inherited entities.
No LLM required.
"""
import uuid
import pytest
import pytest_asyncio
from hindsight_api import MemoryEngine, RequestContext
from hindsight_api.engine.retain import embedding_utils
# Tests in this file insert memory_units with shared hardcoded UUIDs and
# memory_units.id is a global PK; share an xdist group so parallel workers
# don't collide on the same row IDs.
pytestmark = pytest.mark.xdist_group("recall_observation_entities")
ID_FACT = "11111111-0000-0000-0000-000000000001"
ID_OBS_INHERITED = "11111111-0000-0000-0000-000000000002"
ID_OBS_DIRECT = "11111111-0000-0000-0000-000000000003"
RC = RequestContext(tenant_id="default")
def _to_str(emb: list[float]) -> str:
return "[" + ",".join(str(v) for v in emb) + "]"
@pytest_asyncio.fixture
async def seeded(memory_no_llm_verify: MemoryEngine):
engine = memory_no_llm_verify
bank_id = f"test-recall-obs-ent-{uuid.uuid4().hex[:8]}"
await engine.get_bank_profile(bank_id, request_context=RC)
embeddings = await embedding_utils.generate_embeddings_batch(
engine.embeddings,
[
"HeadClaw waitlist tracked in Google Sheets",
"HeadClaw users sign up via the waitlist",
"Reddit thread mentions HeadClaw waitlist signups",
],
)
pool = await engine._get_pool()
async with pool.acquire() as conn:
await conn.execute(
"DELETE FROM memory_units WHERE id IN ($1, $2, $3)",
ID_FACT,
ID_OBS_INHERITED,
ID_OBS_DIRECT,
)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
# Source fact with two entities. The observation that lacks direct
# entity rows must inherit both through source_memory_ids.
headclaw_id = await conn.fetchval(
"""
INSERT INTO entities (bank_id, canonical_name, mention_count)
VALUES ($1, $2, 1) RETURNING id
""",
bank_id,
"HeadClaw",
)
waitlist_id = await conn.fetchval(
"""
INSERT INTO entities (bank_id, canonical_name, mention_count)
VALUES ($1, $2, 1) RETURNING id
""",
bank_id,
"waitlist users",
)
# Independent entity attached directly to the second observation —
# exercises the existing direct-link path so we don't regress it.
reddit_id = await conn.fetchval(
"""
INSERT INTO entities (bank_id, canonical_name, mention_count)
VALUES ($1, $2, 1) RETURNING id
""",
bank_id,
"Reddit",
)
await conn.execute(
"""
INSERT INTO memory_units (id, bank_id, text, fact_type, embedding, event_date)
VALUES ($1, $2, $3, 'world', $4::vector, now())
""",
ID_FACT,
bank_id,
"HeadClaw waitlist tracked in Google Sheets",
_to_str(embeddings[0]),
)
await conn.execute(
"""
INSERT INTO unit_entities (unit_id, entity_id) VALUES ($1, $2), ($1, $3)
""",
ID_FACT,
headclaw_id,
waitlist_id,
)
# Observation with NO direct unit_entities — must inherit HeadClaw +
# waitlist users from source_memory_ids.
await conn.execute(
"""
INSERT INTO memory_units (
id, bank_id, text, fact_type, embedding, event_date,
source_memory_ids, history, proof_count
)
VALUES ($1, $2, $3, 'observation', $4::vector, now(), $5::uuid[], '[]'::jsonb, 1)
""",
ID_OBS_INHERITED,
bank_id,
"HeadClaw users sign up via the waitlist",
_to_str(embeddings[1]),
[ID_FACT],
)
# Observation with a DIRECT unit_entities link — must keep its own entity.
await conn.execute(
"""
INSERT INTO memory_units (
id, bank_id, text, fact_type, embedding, event_date,
source_memory_ids, history, proof_count
)
VALUES ($1, $2, $3, 'observation', $4::vector, now(), NULL, '[]'::jsonb, 1)
""",
ID_OBS_DIRECT,
bank_id,
"Reddit thread mentions HeadClaw waitlist signups",
_to_str(embeddings[2]),
)
await conn.execute(
"INSERT INTO unit_entities (unit_id, entity_id) VALUES ($1, $2)",
ID_OBS_DIRECT,
reddit_id,
)
yield engine, bank_id
await engine.delete_bank(bank_id, request_context=RC)
@pytest.mark.asyncio
async def test_recall_includes_inherited_entities_for_observations(seeded):
"""Observation-only recall must surface entities inherited from source memories."""
engine, bank_id = seeded
result = await engine.recall_async(
bank_id=bank_id,
query="HeadClaw waitlist users",
fact_type=["observation"],
request_context=RC,
max_tokens=4000,
include_entities=True,
max_entity_tokens=2000,
)
by_id = {str(r.id): r for r in result.results}
assert ID_OBS_INHERITED in by_id, f"Expected inherited-entity observation in results, got {list(by_id)}"
assert ID_OBS_DIRECT in by_id, f"Expected direct-entity observation in results, got {list(by_id)}"
inherited = by_id[ID_OBS_INHERITED].entities or []
assert "HeadClaw" in inherited, f"Observation entities must inherit through source_memory_ids; got {inherited}"
assert "waitlist users" in inherited, f"All source entities should propagate; got {inherited}"
direct = by_id[ID_OBS_DIRECT].entities or []
assert "Reddit" in direct, f"Direct unit_entities link must still project on observation results; got {direct}"
assert result.entities is not None, "Top-level entities map must populate when include_entities=True"
aggregate_names = set(result.entities.keys())
assert {"HeadClaw", "waitlist users", "Reddit"}.issubset(aggregate_names), (
f"Top-level entities map must include inherited + direct entities; got {aggregate_names}"
)
@pytest.mark.asyncio
async def test_get_memory_unit_inherits_observation_entities(seeded):
"""get_memory_unit shares the recall helper, so observation inheritance
must keep working through the per-memory endpoint as well.
"""
engine, bank_id = seeded
inherited = await engine.get_memory_unit(
memory_id=ID_OBS_INHERITED,
bank_id=bank_id,
request_context=RC,
)
assert inherited is not None
assert set(inherited["entities"]) >= {"HeadClaw", "waitlist users"}, (
f"Observation must inherit source-memory entities; got {inherited['entities']}"
)
direct = await engine.get_memory_unit(
memory_id=ID_OBS_DIRECT,
bank_id=bank_id,
request_context=RC,
)
assert direct is not None
assert "Reddit" in direct["entities"], (
f"Direct unit_entities link must still resolve via get_memory_unit; got {direct['entities']}"
)
@@ -1,41 +0,0 @@
import importlib
import sys
from unittest.mock import MagicMock, patch
def _drop_reflect_modules() -> None:
for name in list(sys.modules):
if name == "hindsight_api.engine.reflect" or name.startswith("hindsight_api.engine.reflect."):
sys.modules.pop(name)
def test_reflect_import_does_not_load_tiktoken_encoding():
_drop_reflect_modules()
with patch("tiktoken.get_encoding") as get_encoding:
reflect = importlib.import_module("hindsight_api.engine.reflect")
get_encoding.assert_not_called()
assert reflect.run_reflect_agent is not None
def test_reflect_token_counting_loads_tiktoken_encoding_when_used():
_drop_reflect_modules()
fake_encoding = MagicMock()
fake_encoding.encode.side_effect = lambda text: text.split()
with patch("tiktoken.get_encoding", return_value=fake_encoding) as get_encoding:
agent = importlib.import_module("hindsight_api.engine.reflect.agent")
prompts = importlib.import_module("hindsight_api.engine.reflect.prompts")
count = agent._count_messages_tokens([{"role": "user", "content": "one two"}])
final_prompt = prompts.build_final_prompt(
query="What happened?",
context_history=[{"tool": "recall", "output": {"answer": "three four"}}],
bank_profile={"name": "test"},
max_context_tokens=1000,
)
assert count == 2
assert "three four" in final_prompt
get_encoding.assert_called_once_with("cl100k_base")
@@ -1,120 +0,0 @@
"""Regression tests for reflect tool helpers."""
import re
import uuid
import pytest
from hindsight_api.engine.reflect.tools import _document_metadata_from_retain_params, tool_expand
class _FakeReflectConnection:
"""Tiny asyncpg-like connection for tool_expand query behavior."""
def __init__(self, bank_id: str, memory_id: uuid.UUID, document_id: str, chunk_id: str | None) -> None:
self.bank_id = bank_id
self.memory_id = memory_id
self.document_id = document_id
self.chunk_id = chunk_id
async def fetch(self, query: str, *args):
normalized_query = re.sub(r"\s+", " ", query).strip()
if "FROM public.memory_units" in normalized_query:
return [
{
"id": self.memory_id,
"text": "The user prefers test-first bug fixes.",
"chunk_id": self.chunk_id,
"document_id": self.document_id,
"fact_type": "experience",
"context": "preference",
}
]
if "FROM public.chunks" in normalized_query:
if self.chunk_id is None:
return []
return [
{
"chunk_id": self.chunk_id,
"chunk_text": "The user prefers test-first bug fixes.",
"chunk_index": 0,
"document_id": self.document_id,
}
]
if "FROM public.documents" in normalized_query:
select_clause = normalized_query.split(" FROM ", 1)[0]
assert " metadata," not in f" {select_clause},", (
"tool_expand must not query documents.metadata; that column was removed and "
"document metadata now lives in retain_params.metadata"
)
return [
{
"id": self.document_id,
"original_text": "The user prefers test-first bug fixes.",
"retain_params": {"metadata": {"source": "regression-test"}},
}
]
raise AssertionError(f"Unexpected query: {normalized_query}")
@pytest.mark.asyncio
async def test_tool_expand_document_depth_reads_metadata_from_retain_params() -> None:
"""Document expansion must work after documents.metadata has been dropped."""
bank_id = "test-reflect-expand-retain-params-metadata"
memory_id = uuid.uuid4()
document_id = "doc-reflect-expand"
chunk_id = "chunk-reflect-expand"
conn = _FakeReflectConnection(bank_id, memory_id, document_id, chunk_id)
result = await tool_expand(
conn=conn,
bank_id=bank_id,
memory_ids=[str(memory_id)],
depth="document",
)
assert result["count"] == 1
document = result["results"][0]["document"]
assert document["metadata"] == {"source": "regression-test"}
assert document["retain_params"] == {"metadata": {"source": "regression-test"}}
@pytest.mark.asyncio
async def test_tool_expand_document_depth_without_chunk_reads_metadata_from_retain_params() -> None:
"""Direct document expansion follows the same metadata source contract."""
bank_id = "test-reflect-expand-direct-retain-params-metadata"
memory_id = uuid.uuid4()
document_id = "doc-reflect-expand-direct"
conn = _FakeReflectConnection(bank_id, memory_id, document_id, chunk_id=None)
result = await tool_expand(
conn=conn,
bank_id=bank_id,
memory_ids=[str(memory_id)],
depth="document",
)
assert result["count"] == 1
document = result["results"][0]["document"]
assert document["metadata"] == {"source": "regression-test"}
assert document["retain_params"] == {"metadata": {"source": "regression-test"}}
def test_document_metadata_from_retain_params_accepts_json_strings() -> None:
"""asyncpg JSONB codecs may return retain_params as a dict or JSON string."""
retain_params = '{"metadata": {"source": "json-string"}}'
assert _document_metadata_from_retain_params(retain_params) == {"source": "json-string"}
@pytest.mark.parametrize(
"retain_params",
[None, [], "not json", {"metadata": ["not", "a", "dict"]}],
)
def test_document_metadata_from_retain_params_ignores_invalid_values(retain_params) -> None:
"""Malformed retain_params should not break reflect expansion."""
assert _document_metadata_from_retain_params(retain_params) is None
@@ -401,7 +401,6 @@ class TestRecallWithObservationsAndMentalModels:
class TestReflectUsesMentalModels:
"""Test that reflect searches and uses mental models when available."""
@pytest.mark.hs_llm_mat
@pytest.mark.asyncio
async def test_reflect_searches_mental_models_when_available(self, memory: MemoryEngine, request_context):
"""Test that reflect uses search_mental_models when the bank has mental models.
-1
View File
@@ -13,7 +13,6 @@ from hindsight_api.engine.memory_engine import Budget
logger = logging.getLogger(__name__)
@pytest.mark.hs_llm_mat
@pytest.mark.asyncio
async def test_retain_with_chunks(memory, request_context):
"""
@@ -1,109 +0,0 @@
from pathlib import Path
from hindsight_api._vector_index import (
SCANN_MIN_ROWS_FOR_AUTO_INDEX,
bootstrap_extension,
index_type_keyword,
index_using_clause,
pg_extension_name,
should_defer_index_creation,
uses_per_bank_vector_indexes,
validate_extension,
)
from hindsight_api.engine.retain import bank_utils
class RecordingConn:
def __init__(self):
self.statements = []
def execute(self, statement, *args, **kwargs):
self.statements.append(str(statement))
def test_validate_extension_accepts_scann():
assert validate_extension("scann") == "scann"
assert validate_extension("ScaNN") == "scann"
def test_pg_extension_name_maps_scann_to_alloydb_extension():
assert pg_extension_name("scann") == "alloydb_scann"
def test_index_using_clause_scann_uses_cosine_auto_mode():
clause = index_using_clause("scann")
assert "USING scann (embedding cosine)" in clause
assert "mode = 'AUTO'" in clause
def test_index_using_clause_pgvector_matches_existing_clause():
assert index_using_clause("pgvector") == "USING hnsw (embedding vector_cosine_ops)"
def test_index_type_keyword_scann_round_trips_pg_indexes_indexdef():
keyword = index_type_keyword("scann")
indexdef = "CREATE INDEX idx ON memory_units USING scann (embedding cosine) WITH (mode='AUTO')"
assert keyword == "scann"
assert keyword in indexdef.lower()
def test_bootstrap_extension_scann_installs_vector_before_alloydb_scann():
conn = RecordingConn()
bootstrap_extension(conn, "scann")
assert conn.statements == [
"CREATE EXTENSION IF NOT EXISTS vector",
"CREATE EXTENSION IF NOT EXISTS alloydb_scann CASCADE",
]
def test_scann_index_creation_defers_until_table_is_large_enough():
assert should_defer_index_creation("scann", 0)
assert should_defer_index_creation("scann", SCANN_MIN_ROWS_FOR_AUTO_INDEX - 1)
assert not should_defer_index_creation("scann", SCANN_MIN_ROWS_FOR_AUTO_INDEX)
assert not should_defer_index_creation("pgvector", 0)
def test_scann_does_not_use_per_bank_partial_indexes():
assert not uses_per_bank_vector_indexes("scann")
assert uses_per_bank_vector_indexes("pgvector")
assert uses_per_bank_vector_indexes("pgvectorscale")
assert uses_per_bank_vector_indexes("vchord")
def test_alembic_vector_migrations_freeze_vector_sql_locally():
migration_dir = Path(__file__).resolve().parent.parent / "hindsight_api/alembic/versions"
changed_migrations = [
"5a366d414dce_initial_schema.py",
"a4b5c6d7e8f9_fix_per_bank_vector_index_type.py",
"d5e6f7a8b9c0_add_bank_internal_id_and_per_bank_hnsw.py",
"n9i0j1k2l3m4_learnings_and_pinned_reflections.py",
]
for migration in changed_migrations:
text = (migration_dir / migration).read_text()
assert "hindsight_api._vector_index" not in text
class RecordingOps:
def __init__(self):
self.called = False
async def create_bank_vector_indexes(self, *args, **kwargs):
self.called = True
class ScannConfig:
vector_extension = "scann"
async def test_create_bank_vector_indexes_skips_scann(monkeypatch):
monkeypatch.setattr(bank_utils, "get_config", lambda: ScannConfig())
ops = RecordingOps()
await bank_utils.create_bank_vector_indexes(None, "bank", "00000000-0000-0000-0000-000000000000", ops=ops)
assert not ops.called
+7 -154
View File
@@ -56,15 +56,13 @@ async def pool(backend):
@pytest_asyncio.fixture
async def clean_operations(pool):
"""Clean up async_operations table before and after tests.
We must clean ALL pending operations (not just test-worker-* prefixed ones)
because WorkerPoller.claim_batch scans the entire schema for pending tasks.
Stale operations left by other tests (e.g. consolidation) cause spurious
failures when the poller picks them up unexpectedly.
"""
await pool.execute("DELETE FROM async_operations WHERE status = 'pending'")
"""Clean up async_operations table before and after tests."""
# Clean before test - covers both 'test-worker-' and 'test_worker_recovery' patterns
await pool.execute(
"DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'"
)
yield
# Clean after test
await pool.execute(
"DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'"
)
@@ -2394,75 +2392,6 @@ async def test_pending_breakdown_explains_unclaimable_rows(pool, backend, clean_
assert buckets["consolidation"]["claimable"] >= 1
class TestSummariseChildErrorMessages:
"""Pure unit tests for the _summarise_child_error_messages helper.
The helper picks a representative error message for a parent whose
children failed. The integration tests above exercise the full path
through _mark_failed; these tests focus on the choice itself.
"""
def _sib(self, status: str, error_message: str | None = None) -> dict:
return {"status": status, "error_message": error_message}
def test_all_failed_with_same_message_inherits_that_message(self):
from hindsight_api.worker.poller import _summarise_child_error_messages
siblings = [
self._sib("failed", "boom"),
self._sib("failed", "boom"),
self._sib("failed", "boom"),
]
assert _summarise_child_error_messages(siblings) == "boom"
def test_mixed_failed_messages_picks_most_common(self):
from hindsight_api.worker.poller import _summarise_child_error_messages
siblings = [
self._sib("failed", "common cause"),
self._sib("failed", "common cause"),
self._sib("failed", "rare cause"),
]
assert _summarise_child_error_messages(siblings) == "common cause"
def test_completed_siblings_ignored(self):
from hindsight_api.worker.poller import _summarise_child_error_messages
siblings = [
self._sib("completed", None),
self._sib("completed", None),
self._sib("failed", "the one real failure"),
]
assert _summarise_child_error_messages(siblings) == "the one real failure"
def test_no_failed_siblings_falls_back_to_generic(self):
from hindsight_api.worker.poller import _summarise_child_error_messages
siblings = [
self._sib("completed", None),
self._sib("completed", None),
]
assert _summarise_child_error_messages(siblings) == "One or more sub-batches failed"
def test_failed_siblings_with_no_error_message_falls_back_to_generic(self):
from hindsight_api.worker.poller import _summarise_child_error_messages
siblings = [
self._sib("failed", None),
self._sib("failed", ""),
]
assert _summarise_child_error_messages(siblings) == "One or more sub-batches failed"
def test_whitespace_only_messages_treated_as_empty(self):
from hindsight_api.worker.poller import _summarise_child_error_messages
siblings = [
self._sib("failed", " "),
self._sib("failed", "actual error"),
]
assert _summarise_child_error_messages(siblings) == "actual error"
class TestMarkFailedParentPropagation:
"""Tests for _mark_failed parent propagation in WorkerPoller.
@@ -2542,21 +2471,10 @@ class TestMarkFailedParentPropagation:
assert "DB constraint violation" in child2_row["error_message"]
# parent must now be failed (all siblings done, at least one failed)
parent_row = await pool.fetchrow(
"SELECT status, error_message FROM async_operations WHERE operation_id = $1",
parent_id,
)
parent_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", parent_id)
assert parent_row["status"] == "failed", (
f"Parent should be 'failed' when last sibling fails, got '{parent_row['status']}'"
)
# Parent error_message must propagate the child's actual error reason,
# not the legacy generic "One or more sub-batches failed". Without this,
# downstream filters that classify failures by error_message lose all
# signal once a batch has children.
assert "DB constraint violation" in (parent_row["error_message"] or ""), (
f"Parent error_message should inherit child's reason, "
f"got: {parent_row['error_message']!r}"
)
@pytest.mark.asyncio
async def test_mark_failed_finalises_parent_when_last_sibling_is_sole_child(self, pool, backend, clean_operations):
@@ -2876,71 +2794,6 @@ class TestClaimBatchRotation:
finally:
await pool.execute("DELETE FROM async_operations WHERE operation_id = $1", op_id)
@pytest.mark.asyncio
async def test_scan_uses_optional_routine_when_installed(self, pool, backend, clean_operations):
"""When ``public.schemas_with_pending_work()`` exists in pg_proc,
``_scan_active_schemas`` invokes it instead of running per-schema
EXISTS queries from Python. Confirms the OptionalRoutines probe
path is wired correctly end-to-end.
The routine body installed here is a minimal stand-in that
satisfies the contract documented on
``optional_routines.SCHEMAS_WITH_PENDING_WORK`` Hindsight does
not own the canonical implementation.
"""
from hindsight_api.engine.db.postgresql import PostgresConnection
from hindsight_api.worker import WorkerPoller
# Minimal contract-satisfying implementation: returns the empty
# set. Enough to prove the poller follows the server-side path.
await pool.execute(
"CREATE OR REPLACE FUNCTION public.schemas_with_pending_work() "
"RETURNS SETOF text AS $$ BEGIN RETURN; END $$ LANGUAGE plpgsql STABLE"
)
try:
poller = WorkerPoller(
backend=backend,
worker_id="test-routine",
executor=lambda x: None,
)
captured_fetch: list[str] = []
captured_fetchval: list[str] = []
original_fetch = PostgresConnection.fetch
original_fetchval = PostgresConnection.fetchval
async def spy_fetch(self, query, *args, timeout=None):
captured_fetch.append(query)
return await original_fetch(self, query, *args, timeout=timeout)
async def spy_fetchval(self, query, *args, column=0, timeout=None):
captured_fetchval.append(query)
return await original_fetchval(self, query, *args, column=column, timeout=timeout)
PostgresConnection.fetch = spy_fetch # type: ignore[method-assign]
PostgresConnection.fetchval = spy_fetchval # type: ignore[method-assign]
try:
await poller._scan_active_schemas([None])
finally:
PostgresConnection.fetch = original_fetch # type: ignore[method-assign]
PostgresConnection.fetchval = original_fetchval # type: ignore[method-assign]
# Routine was probed (one pg_proc lookup) and then invoked.
assert any("pg_proc" in q for q in captured_fetchval), (
f"Expected a pg_proc existence probe; fetchval queries: {captured_fetchval}"
)
assert any("schemas_with_pending_work" in q for q in captured_fetch), (
f"Expected schemas_with_pending_work() to be invoked; fetch queries: {captured_fetch}"
)
# Fallback per-schema EXISTS path must NOT have run.
assert not any("async_operations" in q and "EXISTS" in q for q in captured_fetchval), (
f"Fallback EXISTS path should be skipped when routine is installed; fetchval queries: {captured_fetchval}"
)
# Probe result is cached so the next scan skips the pg_proc lookup.
assert poller._optional_routines._cache.get("schemas_with_pending_work") is True
finally:
await pool.execute("DROP FUNCTION IF EXISTS public.schemas_with_pending_work()")
@pytest.mark.asyncio
async def test_claim_batch_only_queries_active_schemas(self, pool, backend, clean_operations):
"""claim_batch uses _scan_active_schemas to pre-filter, then
@@ -1,35 +0,0 @@
"""Tests for hindsight_api.worker.main entry-point helpers."""
import asyncio
import signal
from unittest.mock import MagicMock
from hindsight_api.worker.main import _install_shutdown_signal_handlers
def test_install_shutdown_signal_handlers_unix_path():
"""On platforms where asyncio supports signal handlers (Unix), both
SIGINT and SIGTERM are registered and the helper reports success."""
loop = MagicMock(spec=asyncio.AbstractEventLoop)
handler = MagicMock()
installed = _install_shutdown_signal_handlers(loop, handler)
assert installed is True
loop.add_signal_handler.assert_any_call(signal.SIGINT, handler)
loop.add_signal_handler.assert_any_call(signal.SIGTERM, handler)
assert loop.add_signal_handler.call_count == 2
def test_install_shutdown_signal_handlers_windows_path():
"""On Windows, asyncio's ProactorEventLoop raises NotImplementedError
from add_signal_handler. The helper must swallow it and report failure
so the worker keeps running with default Python signal behavior
(regression test for issue #1411)."""
loop = MagicMock(spec=asyncio.AbstractEventLoop)
loop.add_signal_handler.side_effect = NotImplementedError
handler = MagicMock()
installed = _install_shutdown_signal_handlers(loop, handler)
assert installed is False
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "hindsight-api"
version = "0.6.2"
version = "0.5.6"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"hindsight-api-slim[all]==0.6.2",
"hindsight-api-slim[all]>=0.4.17",
]
[tool.uv.sources]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.6.2"
version = "0.5.6"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
+2 -6
View File
@@ -269,7 +269,6 @@ impl ApiClient {
bank_id: &str,
files: Vec<(String, Vec<u8>)>,
context: Option<String>,
strategy: Option<String>,
verbose: bool,
) -> Result<FileRetainResult> {
self.runtime.block_on(async {
@@ -285,9 +284,6 @@ impl ApiClient {
if let Some(ctx) = &context {
meta["context"] = serde_json::Value::String(ctx.clone());
}
if let Some(strat) = &strategy {
meta["strategy"] = serde_json::Value::String(strat.clone());
}
// Use filename stem as document_id for deduplication
if let Some(stem) = std::path::Path::new(name)
.file_stem()
@@ -1262,8 +1258,8 @@ impl ApiClient {
// Re-export types from the generated client for use in commands
pub use types::{
BankProfileResponse, MemoryItem, MemoryItemTimestamp, RecallRequest, RecallResponse,
RecallResult, ReflectRequest, ReflectResponse, RetainRequest,
BankProfileResponse, MemoryItem, RecallRequest, RecallResponse, RecallResult, ReflectRequest,
ReflectResponse, RetainRequest,
};
#[cfg(test)]
+3 -18
View File
@@ -3,9 +3,7 @@ use std::fs;
use std::path::PathBuf;
use walkdir::WalkDir;
use crate::api::{
ApiClient, MemoryItem, MemoryItemTimestamp, RecallRequest, ReflectRequest, RetainRequest,
};
use crate::api::{ApiClient, MemoryItem, RecallRequest, ReflectRequest, RetainRequest};
use crate::config;
use crate::output::{self, OutputFormat};
use crate::ui;
@@ -440,7 +438,6 @@ pub fn retain(
content: String,
doc_id: Option<String>,
context: Option<String>,
timestamp: Option<String>,
r#async: bool,
document_tags: Option<Vec<String>>,
verbose: bool,
@@ -454,20 +451,11 @@ pub fn retain(
None
};
// MemoryItem.timestamp is a progenitor anyOf enum; round-trip through JSON to pick the matching variant.
let timestamp = match timestamp {
Some(s) => Some(
serde_json::from_value::<MemoryItemTimestamp>(serde_json::Value::String(s.clone()))
.with_context(|| format!("invalid --timestamp value: {:?}", s))?,
),
None => None,
};
let item = MemoryItem {
content: content.clone(),
context,
metadata: None,
timestamp,
timestamp: None,
document_id: Some(doc_id.clone()),
entities: None,
tags: None,
@@ -510,7 +498,6 @@ pub fn retain(
}
}
#[allow(clippy::too_many_arguments)]
pub fn retain_files(
client: &ApiClient,
agent_id: &str,
@@ -518,7 +505,6 @@ pub fn retain_files(
recursive: bool,
context: Option<String>,
r#async: bool,
strategy: Option<String>,
verbose: bool,
output_format: OutputFormat,
) -> Result<()> {
@@ -581,8 +567,7 @@ pub fn retain_files(
pb.inc(1);
}
let result =
client.file_retain(agent_id, file_data, context.clone(), strategy.clone(), verbose)?;
let result = client.file_retain(agent_id, file_data, context.clone(), verbose)?;
all_operation_ids.extend(result.operation_ids);
}
-14
View File
@@ -591,12 +591,6 @@ enum MemoryCommands {
#[arg(short = 'c', long)]
context: Option<String>,
/// When the content occurred (ISO 8601 datetime, e.g. 2024-01-15T10:30:00Z
/// or 2024-01-15). Pass "unset" to store without a timestamp.
/// Omit to default to now.
#[arg(short = 't', long)]
timestamp: Option<String>,
/// Queue for background processing
#[arg(long)]
r#async: bool,
@@ -625,10 +619,6 @@ enum MemoryCommands {
/// Queue for background processing
#[arg(long)]
r#async: bool,
/// Named retain strategy to use for these files (overrides the bank's default strategy)
#[arg(short = 's', long)]
strategy: Option<String>,
},
/// Delete a memory unit
@@ -1440,7 +1430,6 @@ fn run() -> Result<()> {
content,
doc_id,
context,
timestamp,
r#async,
document_tags,
} => commands::memory::retain(
@@ -1449,7 +1438,6 @@ fn run() -> Result<()> {
content,
doc_id,
context,
timestamp,
r#async,
document_tags,
verbose,
@@ -1461,7 +1449,6 @@ fn run() -> Result<()> {
recursive,
context,
r#async,
strategy,
} => commands::memory::retain_files(
&client,
&bank_id,
@@ -1469,7 +1456,6 @@ fn run() -> Result<()> {
recursive,
context,
r#async,
strategy,
verbose,
output_format,
),
-19
View File
@@ -91,25 +91,6 @@ fn test_ui_command_with_config() {
std::fs::remove_dir_all(&temp_dir).ok();
}
#[test]
fn test_memory_retain_exposes_timestamp_flag() {
// Regression: `hindsight memory retain` historically had no way to set the
// memory's event date even though the SDKs do. The flag must appear in
// --help so users (and docs) can discover it.
let output = Command::new("cargo")
.args(["run", "--", "memory", "retain", "--help"])
.output()
.expect("Failed to execute command");
assert!(output.status.success(), "retain --help failed");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("--timestamp") && stdout.contains("-t"),
"expected --timestamp/-t flag in retain --help, got: {}",
stdout
);
}
#[test]
fn test_configure_command() {
// Test that configure command creates/updates config
+2 -11
View File
@@ -7,7 +7,7 @@ info:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
title: Hindsight HTTP API
version: 0.6.2
version: 0.5.6
servers:
- url: /
paths:
@@ -3666,7 +3666,7 @@ components:
- updates
title: BankConfigUpdate
BankListItem:
description: Bank list item with profile summary and stats.
description: Bank list item with profile summary.
properties:
bank_id:
title: Bank Id
@@ -3685,13 +3685,6 @@ components:
updated_at:
nullable: true
type: string
fact_count:
default: 0
title: Fact Count
type: integer
last_document_at:
nullable: true
type: string
required:
- bank_id
- disposition
@@ -3706,8 +3699,6 @@ components:
empathy: 3
literalism: 3
skepticism: 3
fact_count: 156
last_document_at: 2024-01-16T14:20:00Z
mission: I am a software engineer helping my team ship quality code
name: Alice
updated_at: 2024-01-16T14:20:00Z
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.6.2
API version: 0.5.6
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.6.2
API version: 0.5.6
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.6.2
API version: 0.5.6
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.6.2
API version: 0.5.6
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.6.2
API version: 0.5.6
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.6.2
API version: 0.5.6
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.

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